scripts/sqlc-unused counts a call from a package covered by a ".*" entry of scripts/deadcode-testonly.txt as a test use, so one list defines the test-support packages for both checks, and the list's header says so. A query that only the kit's builders call now reads TEST-ONLY, not used. The check reads the module in a directory it is given, which lets a test run it over a fixture module. New tests: TestScanCountsATestSupportPackageAsTestUse, over a fixture module whose kit package and app package each call one query, and TestReadSupportTakesTheWholePackageEntries. The other tests, TestCheck and TestReadAllow, are unchanged. The output of go run ./scripts/sqlc-unused equals the baseline taken before step 2 once each line's count of test references is stripped. 2 breaks, each failed the new tests; they have no old side.
429 lines
12 KiB
Go
429 lines
12 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
// Command sqlc-unused reports the sqlc queries no production code calls.
|
|
//
|
|
// A sqlc package is a package whose generated header names sqlc; its queries
|
|
// are the "-- name:" lines of the queries/*.sql files beside the generated
|
|
// code. A query counts as used by a selector that resolves to its method on
|
|
// the package's Queries type, or to a method of an interface that *Queries
|
|
// implements (the generated Querier, a narrow local interface, a test fake).
|
|
// Method values count. A use in a _test.go file is a test use, and so is a
|
|
// use in a test-support package, one covered by a ".*" entry of
|
|
// scripts/deadcode-testonly.txt, the list both checks read.
|
|
//
|
|
// The output has one UNUSED line per query with no use at all, one TEST-ONLY
|
|
// line per query only tests call, and a TOTAL line. The allowlist
|
|
// (scripts/sqlc-unused/allow.txt) holds the unused queries that stay, one
|
|
// "<package path>.<Name> # reason (owner)" per line, the package path being
|
|
// relative to the module path. The exit status is 1 when an unused query is
|
|
// not listed or a listed query is used, and 2 when a package fails to load.
|
|
// A test-only query never fails the check.
|
|
//
|
|
// Run it from the repository root: go run ./scripts/sqlc-unused. The
|
|
// packages under status/ (the local notebook) are dropped before anything
|
|
// else, so one that does not compile changes nothing.
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"go/ast"
|
|
"go/types"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"golang.org/x/tools/go/packages"
|
|
)
|
|
|
|
const (
|
|
allowFile = "scripts/sqlc-unused/allow.txt"
|
|
supportFile = "scripts/deadcode-testonly.txt"
|
|
)
|
|
|
|
var nameRe = regexp.MustCompile(`^--\s*name:\s*(\w+)\s+:\w+`)
|
|
|
|
// query is one "-- name:" block of a sqlc package, with where its callers are.
|
|
type query struct {
|
|
key string // module-relative package path and name
|
|
pkgPath string
|
|
name string
|
|
file string
|
|
line int
|
|
prod, tst []string
|
|
}
|
|
|
|
func (q *query) used() bool { return len(q.prod) > 0 }
|
|
func (q *query) testOnly() bool { return len(q.prod) == 0 && len(q.tst) > 0 }
|
|
func (q *query) unused() bool { return len(q.prod) == 0 && len(q.tst) == 0 }
|
|
|
|
func main() {
|
|
os.Exit(run())
|
|
}
|
|
|
|
func run() int {
|
|
root, err := os.Getwd()
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "sqlc-unused:", err)
|
|
return 2
|
|
}
|
|
support, err := readSupport(supportFile)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "sqlc-unused:", err)
|
|
return 2
|
|
}
|
|
queries, err := scan(root, support)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "sqlc-unused:", err)
|
|
return 2
|
|
}
|
|
report(queries)
|
|
|
|
allow, err := readAllow(allowFile)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "sqlc-unused:", err)
|
|
return 2
|
|
}
|
|
problems := check(queries, allow)
|
|
for _, p := range problems {
|
|
fmt.Fprintln(os.Stderr, p)
|
|
}
|
|
if len(problems) > 0 {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// scan finds the queries of the module rooted at root and where each is
|
|
// called. support holds the module-relative paths of the test-support
|
|
// packages.
|
|
func scan(root string, support map[string]bool) (map[string]*query, error) {
|
|
pkgs, err := load(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sqlc := sqlcPackages(pkgs)
|
|
queries, err := readQueries(root, sqlc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s := &scanner{root: root, sqlc: sqlc, queries: queries, support: support, seen: map[string]bool{}}
|
|
for _, p := range pkgs {
|
|
s.scanPackage(p)
|
|
}
|
|
return queries, nil
|
|
}
|
|
|
|
// load reads every package of the module at dir with its tests, drops the
|
|
// ones under status/, and fails on a package error in what remains.
|
|
func load(dir string) ([]*packages.Package, error) {
|
|
cfg := &packages.Config{
|
|
Dir: dir,
|
|
Tests: true,
|
|
Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax |
|
|
packages.NeedTypes | packages.NeedTypesInfo | packages.NeedImports |
|
|
packages.NeedDeps | packages.NeedModule,
|
|
}
|
|
all, err := packages.Load(cfg, "./...")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var pkgs []*packages.Package
|
|
for _, p := range all {
|
|
if !strings.Contains(p.PkgPath, "/status/") {
|
|
pkgs = append(pkgs, p)
|
|
}
|
|
}
|
|
if packages.PrintErrors(pkgs) > 0 {
|
|
return nil, errors.New("packages failed to load")
|
|
}
|
|
return pkgs, nil
|
|
}
|
|
|
|
// sqlcPackages returns the non-test variant of every package that holds a
|
|
// file generated by sqlc, by import path.
|
|
func sqlcPackages(pkgs []*packages.Package) map[string]*packages.Package {
|
|
found := map[string]*packages.Package{}
|
|
for _, p := range pkgs {
|
|
if p.ID != p.PkgPath || strings.HasSuffix(p.PkgPath, ".test") {
|
|
continue
|
|
}
|
|
for _, f := range p.Syntax {
|
|
if generatedBySqlc(f) {
|
|
found[p.PkgPath] = p
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return found
|
|
}
|
|
|
|
// generatedBySqlc reports whether a comment group before the package clause
|
|
// is a "Code generated" header that names sqlc. SPDX lines precede it, so
|
|
// every group before the clause is read.
|
|
func generatedBySqlc(f *ast.File) bool {
|
|
if !ast.IsGenerated(f) {
|
|
return false
|
|
}
|
|
for _, cg := range f.Comments {
|
|
if cg.End() > f.Package {
|
|
break
|
|
}
|
|
if strings.Contains(cg.Text(), "Code generated by sqlc") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// modulePath is a package's path relative to its module path.
|
|
func modulePath(p *packages.Package) string {
|
|
if p.Module == nil {
|
|
return p.PkgPath
|
|
}
|
|
return strings.TrimPrefix(strings.TrimPrefix(p.PkgPath, p.Module.Path), "/")
|
|
}
|
|
|
|
// readQueries reads the "-- name:" lines of each sqlc package's queries/*.sql.
|
|
func readQueries(root string, sqlc map[string]*packages.Package) (map[string]*query, error) {
|
|
queries := map[string]*query{}
|
|
for path, p := range sqlc {
|
|
if len(p.GoFiles) == 0 {
|
|
continue
|
|
}
|
|
files, err := filepath.Glob(filepath.Join(filepath.Dir(p.GoFiles[0]), "queries", "*.sql"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, fn := range files {
|
|
found, err := namesIn(root, fn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, q := range found {
|
|
q.pkgPath = path
|
|
q.key = modulePath(p) + "." + q.name
|
|
queries[path+"."+q.name] = q
|
|
}
|
|
}
|
|
}
|
|
return queries, nil
|
|
}
|
|
|
|
// namesIn lists the queries one .sql file declares.
|
|
func namesIn(root, fn string) ([]*query, error) {
|
|
fh, err := os.Open(fn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = fh.Close() }() // opened read-only; closing loses no data
|
|
rel, err := filepath.Rel(root, fn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []*query
|
|
sc := bufio.NewScanner(fh)
|
|
for line := 1; sc.Scan(); line++ {
|
|
if m := nameRe.FindStringSubmatch(sc.Text()); m != nil {
|
|
out = append(out, &query{name: m[1], file: rel, line: line})
|
|
}
|
|
}
|
|
return out, sc.Err()
|
|
}
|
|
|
|
// scanner counts the uses of the queries across every hand-written file.
|
|
type scanner struct {
|
|
root string
|
|
sqlc map[string]*packages.Package
|
|
queries map[string]*query
|
|
support map[string]bool // module-relative paths of the test-support packages
|
|
seen map[string]bool // file names already walked; a non-test file is in two variants
|
|
}
|
|
|
|
func (s *scanner) scanPackage(p *packages.Package) {
|
|
for _, f := range p.Syntax {
|
|
fn := p.Fset.Position(f.Pos()).Filename
|
|
rel, err := filepath.Rel(s.root, fn)
|
|
if err != nil || s.seen[fn] || ast.IsGenerated(f) || strings.HasPrefix(rel, "status"+string(filepath.Separator)) {
|
|
continue
|
|
}
|
|
s.seen[fn] = true
|
|
test := strings.HasSuffix(fn, "_test.go") || s.support[modulePath(p)]
|
|
ast.Inspect(f, func(n ast.Node) bool {
|
|
if sel, ok := n.(*ast.SelectorExpr); ok {
|
|
s.selector(p, sel, test)
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
}
|
|
|
|
// selector records a use when sel names a method of a query's package.
|
|
func (s *scanner) selector(p *packages.Package, sel *ast.SelectorExpr, test bool) {
|
|
fn, _ := p.TypesInfo.Uses[sel.Sel].(*types.Func)
|
|
if fn == nil {
|
|
return
|
|
}
|
|
recv := fn.Type().(*types.Signature).Recv()
|
|
if recv == nil {
|
|
return
|
|
}
|
|
t := recv.Type()
|
|
if ptr, ok := t.(*types.Pointer); ok {
|
|
t = ptr.Elem()
|
|
}
|
|
pos := p.Fset.Position(sel.Pos())
|
|
rel, err := filepath.Rel(s.root, pos.Filename)
|
|
if err != nil {
|
|
rel = pos.Filename
|
|
}
|
|
where := fmt.Sprintf("%s:%d", rel, pos.Line)
|
|
|
|
if named, ok := t.(*types.Named); ok && named.Obj().Name() == "Queries" && named.Obj().Pkg() != nil {
|
|
s.count(named.Obj().Pkg().Path(), fn.Name(), where, test)
|
|
return
|
|
}
|
|
if iface, ok := t.Underlying().(*types.Interface); ok {
|
|
s.viaInterface(p, iface, fn.Name(), where, test)
|
|
}
|
|
}
|
|
|
|
// viaInterface counts a call through an interface for each sqlc package whose
|
|
// *Queries implements it and has a query of that name. The package's type is
|
|
// the one the calling package imported, because each test variant is
|
|
// type-checked separately.
|
|
func (s *scanner) viaInterface(p *packages.Package, iface *types.Interface, name, where string, test bool) {
|
|
for path := range s.sqlc {
|
|
if s.queries[path+"."+name] == nil {
|
|
continue
|
|
}
|
|
imported := importedPackage(p, path, map[*packages.Package]bool{})
|
|
if imported == nil {
|
|
continue
|
|
}
|
|
obj := imported.Types.Scope().Lookup("Queries")
|
|
if obj != nil && types.Implements(types.NewPointer(obj.Type()), iface) {
|
|
s.count(path, name, where, test)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *scanner) count(pkgPath, name, where string, test bool) {
|
|
q := s.queries[pkgPath+"."+name]
|
|
if q == nil {
|
|
return
|
|
}
|
|
if test {
|
|
q.tst = append(q.tst, where)
|
|
} else {
|
|
q.prod = append(q.prod, where)
|
|
}
|
|
}
|
|
|
|
// importedPackage finds the package with the given path in p's import graph.
|
|
func importedPackage(p *packages.Package, path string, seen map[*packages.Package]bool) *packages.Package {
|
|
if p.PkgPath == path && p.Types != nil {
|
|
return p
|
|
}
|
|
if seen[p] {
|
|
return nil
|
|
}
|
|
seen[p] = true
|
|
for _, imp := range p.Imports {
|
|
if found := importedPackage(imp, path, seen); found != nil {
|
|
return found
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// report prints the UNUSED and TEST-ONLY lines, sorted, then the TOTAL line.
|
|
func report(queries map[string]*query) {
|
|
var lines []string
|
|
var used, testOnly, unused int
|
|
for _, q := range queries {
|
|
switch {
|
|
case q.used():
|
|
used++
|
|
case q.testOnly():
|
|
testOnly++
|
|
lines = append(lines, fmt.Sprintf("TEST-ONLY %s:%d %s (%d test refs, e.g. %s)", q.file, q.line, q.name, len(q.tst), q.tst[0]))
|
|
default:
|
|
unused++
|
|
lines = append(lines, fmt.Sprintf("UNUSED %s:%d %s", q.file, q.line, q.name))
|
|
}
|
|
}
|
|
sort.Strings(lines)
|
|
for _, l := range lines {
|
|
fmt.Println(l)
|
|
}
|
|
fmt.Printf("TOTAL queries=%d prod=%d test-only=%d unused=%d\n", len(queries), used, testOnly, unused)
|
|
}
|
|
|
|
// readAllow reads the allowlist: one "<package path>.<Name>" per line, with a
|
|
// trailing "# reason (owner)"; blank lines and comment lines are ignored.
|
|
func readAllow(path string) (map[string]bool, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
allow := map[string]bool{}
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
if i := strings.Index(line, "#"); i >= 0 {
|
|
line = line[:i]
|
|
}
|
|
if key := strings.TrimSpace(line); key != "" {
|
|
allow[key] = true
|
|
}
|
|
}
|
|
return allow, nil
|
|
}
|
|
|
|
// readSupport reads the test-support packages from the "<package path>.*"
|
|
// entries of the list scripts/deadcode-testonly.txt, which the dead-code
|
|
// check reads too. Any other line is not a package and is ignored.
|
|
func readSupport(path string) (map[string]bool, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
support := map[string]bool{}
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
if i := strings.Index(line, "#"); i >= 0 {
|
|
line = line[:i]
|
|
}
|
|
if pkg, ok := strings.CutSuffix(strings.TrimSpace(line), ".*"); ok && pkg != "" {
|
|
support[pkg] = true
|
|
}
|
|
}
|
|
return support, nil
|
|
}
|
|
|
|
// check returns what fails the allowlist: an unused query that is not listed,
|
|
// and a listed query that is used or does not exist. A test-only query never
|
|
// fails.
|
|
func check(queries map[string]*query, allow map[string]bool) []string {
|
|
var problems []string
|
|
known := map[string]bool{}
|
|
for _, q := range queries {
|
|
known[q.key] = true
|
|
switch {
|
|
case q.unused() && !allow[q.key]:
|
|
problems = append(problems, fmt.Sprintf("sqlc-unused: %s is unused and not in %s", q.key, allowFile))
|
|
case !q.unused() && allow[q.key]:
|
|
problems = append(problems, fmt.Sprintf("sqlc-unused: %s is listed in %s but a caller uses it (stale entry)", q.key, allowFile))
|
|
}
|
|
}
|
|
for key := range allow {
|
|
if !known[key] {
|
|
problems = append(problems, fmt.Sprintf("sqlc-unused: %s is listed in %s but no such query exists (stale entry)", key, allowFile))
|
|
}
|
|
}
|
|
sort.Strings(problems)
|
|
return problems
|
|
}
|