feat: gate schema.sql against tables not created by core migrations

Catches plugin-migration leakage (e.g. the .so-era ctpp_* tables) entering
the generated schema dump and sqlc models.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-11 21:13:51 +08:00
parent 43113fc865
commit 9a0099a95a

99
check_schemapurity.go Normal file
View File

@ -0,0 +1,99 @@
package main
import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
func init() {
register(Check{
Seq: 310,
ID: "32",
Title: "schema.sql contains only tables created by core migrations",
Run: func(ctx *ScanContext, rep *Reporter) {
schemaPath := filepath.Join(ctx.backendDir, "sql", "schema.sql")
migrationsDir := filepath.Join(ctx.backendDir, "sql", "migrations")
if !fileExists(schemaPath) || !dirExists(migrationsDir) {
rep.Skip("no backend/sql/schema.sql + migrations pair to check")
return
}
schemaTables, err := schemaCreatedTables(schemaPath)
if err != nil {
rep.Fatal("reading schema.sql: %v", err)
return
}
migrationTables, err := migrationCreatedTables(migrationsDir)
if err != nil {
rep.Fatal("reading migrations: %v", err)
return
}
var leaked []string
for table := range schemaTables {
if !migrationTables[table] {
leaked = append(leaked, table)
}
}
if len(leaked) > 0 {
sort.Strings(leaked)
rep.Fail("%d table(s) in schema.sql are not created by any core migration (plugin leakage; regenerate with `make schema`, which dumps a clean-room DB)", len(leaked))
for _, t := range leaked {
rep.Findingf("backend/sql/schema.sql — table %q has no CREATE TABLE in backend/sql/migrations", t)
}
return
}
rep.OK("all %d schema.sql tables originate from core migrations", len(schemaTables))
},
})
}
var schemaCreateTableRe = regexp.MustCompile(`(?im)^CREATE TABLE (?:IF NOT EXISTS )?(?:public\.)?([a-zA-Z0-9_]+)`)
// Migrations are hand-written: CREATE TABLE may be indented (inside DO blocks
// etc.), so no line anchor.
var migrationCreateTableRe = regexp.MustCompile(`(?i)CREATE TABLE (?:IF NOT EXISTS )?(?:public\.)?([a-zA-Z0-9_]+)`)
func schemaCreatedTables(path string) (map[string]bool, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
tables := map[string]bool{}
for _, m := range schemaCreateTableRe.FindAllStringSubmatch(string(data), -1) {
tables[strings.ToLower(m[1])] = true
}
return tables, nil
}
// migrationCreatedTables collects every table name a migration creates or
// renames to, from both Up and Down sections (a superset is fine: the check
// only flags schema.sql tables NO migration could have produced).
var migrationRenameRe = regexp.MustCompile(`(?i)ALTER TABLE (?:IF EXISTS )?(?:ONLY )?(?:public\.)?[a-zA-Z0-9_]+ RENAME TO ([a-zA-Z0-9_]+)`)
func migrationCreatedTables(dir string) (map[string]bool, error) {
tables := map[string]bool{"goose_db_version": true}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
if err != nil {
return nil, err
}
for _, m := range migrationCreateTableRe.FindAllStringSubmatch(string(data), -1) {
tables[strings.ToLower(m[1])] = true
}
for _, m := range migrationRenameRe.FindAllStringSubmatch(string(data), -1) {
tables[strings.ToLower(m[1])] = true
}
}
return tables, nil
}