feat(lint): add linter rules for migration and naming conventions

* Implement migration safety rules:
  - MIG001: Warn on CREATE INDEX without CONCURRENT.
  - MIG002: Warn on ALTER TABLE ADD COLUMN NOT NULL without DEFAULT.
  - MIG003: Warn on ALTER TABLE ADD CONSTRAINT without NOT VALID.
* Implement naming conventions rules:
  - NAM001: Warn on non-snake_case table names.
  - NAM002: Warn on non-snake_case column names in CREATE TABLE.
  - NAM003: Warn on non-snake_case function names.
* Add test fixtures for all new rules.
This commit is contained in:
2026-06-27 22:15:02 +02:00
parent 932c83dbad
commit 7fb76bae3d
16 changed files with 1032 additions and 12 deletions
+135
View File
@@ -0,0 +1,135 @@
package lint
import (
"fmt"
"regexp"
"strings"
pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/hein/pgtidy/pkg/diagnostics"
"github.com/hein/pgtidy/pkg/pgast"
)
// reSnakeCase matches valid snake_case identifiers: lowercase letters, digits,
// and underscores, starting with a letter or underscore.
var reSnakeCase = regexp.MustCompile(`^[a-z_][a-z0-9_]*$`)
func isSnakeCase(s string) bool {
return reSnakeCase.MatchString(s)
}
// NAM001 — table names must be snake_case.
type ruleNAM001 struct{}
func (ruleNAM001) ID() string { return "NAM001" }
func (ruleNAM001) Severity() diagnostics.Severity { return diagnostics.SeverityHint }
func (ruleNAM001) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
var out []diagnostics.Diagnostic
for _, raw := range stmts {
cs, ok := raw.Stmt.GetNode().(*pg_query.Node_CreateStmt)
if !ok {
continue
}
name := cs.CreateStmt.Relation.Relname
if name == "" || isSnakeCase(name) {
continue
}
line, col := pgast.LocationToLineCol(src, pgast.FirstTokenOffset(src, int(raw.StmtLocation)))
out = append(out, diagnostics.Diagnostic{
RuleID: "NAM001",
Severity: diagnostics.SeverityHint,
Message: fmt.Sprintf("table name %q is not snake_case (expected %q)", name, toSnakeCase(name)),
Line: line,
Col: col,
})
}
return out
}
// NAM002 — column names in CREATE TABLE must be snake_case.
type ruleNAM002 struct{}
func (ruleNAM002) ID() string { return "NAM002" }
func (ruleNAM002) Severity() diagnostics.Severity { return diagnostics.SeverityHint }
func (ruleNAM002) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
var out []diagnostics.Diagnostic
for _, raw := range stmts {
cs, ok := raw.Stmt.GetNode().(*pg_query.Node_CreateStmt)
if !ok {
continue
}
for _, elt := range cs.CreateStmt.TableElts {
cd, ok := elt.GetNode().(*pg_query.Node_ColumnDef)
if !ok {
continue
}
name := cd.ColumnDef.Colname
if name == "" || isSnakeCase(name) {
continue
}
line, col := pgast.LocationToLineCol(src, int(cd.ColumnDef.Location))
out = append(out, diagnostics.Diagnostic{
RuleID: "NAM002",
Severity: diagnostics.SeverityHint,
Message: fmt.Sprintf("column name %q is not snake_case (expected %q)", name, toSnakeCase(name)),
Line: line,
Col: col,
})
}
}
return out
}
// NAM003 — function/procedure names must be snake_case.
type ruleNAM003 struct{}
func (ruleNAM003) ID() string { return "NAM003" }
func (ruleNAM003) Severity() diagnostics.Severity { return diagnostics.SeverityHint }
func (ruleNAM003) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
var out []diagnostics.Diagnostic
for _, raw := range stmts {
cf, ok := raw.Stmt.GetNode().(*pg_query.Node_CreateFunctionStmt)
if !ok {
continue
}
// Funcname is a list of String nodes: [schema, funcname] or [funcname].
name := lastStringNode(cf.CreateFunctionStmt.Funcname)
if name == "" || isSnakeCase(name) {
continue
}
line, col := pgast.LocationToLineCol(src, pgast.FirstTokenOffset(src, int(raw.StmtLocation)))
out = append(out, diagnostics.Diagnostic{
RuleID: "NAM003",
Severity: diagnostics.SeverityHint,
Message: fmt.Sprintf("function name %q is not snake_case (expected %q)", name, toSnakeCase(name)),
Line: line,
Col: col,
})
}
return out
}
// lastStringNode returns the string value of the last node in a name-list
// (schema-qualified names like [schema, name] → name).
func lastStringNode(nodes []*pg_query.Node) string {
for i := len(nodes) - 1; i >= 0; i-- {
sv, ok := nodes[i].GetNode().(*pg_query.Node_String_)
if ok {
return sv.String_.Sval
}
}
return ""
}
// toSnakeCase converts a CamelCase or mixed-case identifier to snake_case as a
// suggested fix shown in the diagnostic message.
var reWordBoundary = regexp.MustCompile(`([a-z0-9])([A-Z])`)
func toSnakeCase(s string) string {
s = reWordBoundary.ReplaceAllString(s, "${1}_${2}")
return strings.ToLower(s)
}