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
+119
View File
@@ -0,0 +1,119 @@
// Package lint provides the rule engine and built-in rule packs for PgTidy.
//
// Usage:
//
// eng := lint.New()
// diags, err := eng.Check(sql, filename)
package lint
import (
"sort"
pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/hein/pgtidy/pkg/diagnostics"
"github.com/hein/pgtidy/pkg/pgast"
)
// Rule is implemented by each lint rule.
type Rule interface {
// ID returns the stable rule identifier (e.g. "MIG001").
ID() string
// Severity returns the default severity for findings from this rule.
Severity() diagnostics.Severity
// Check inspects the parsed statement list and returns any findings.
// src is the original SQL string, used for location lookups.
Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic
}
// Engine runs a set of rules against SQL input.
type Engine struct {
rules []Rule
}
// New returns an Engine loaded with all built-in rules.
func New() *Engine {
e := &Engine{}
e.registerAll()
return e
}
// Custom returns an empty Engine; use Register to add rules.
func Custom() *Engine {
return &Engine{}
}
func (e *Engine) registerAll() {
e.Register(ruleMIG001{})
e.Register(ruleMIG002{})
e.Register(ruleMIG003{})
e.Register(ruleCOR001{})
e.Register(ruleCOR002{})
e.Register(ruleCOR003{})
e.Register(ruleNAM001{})
e.Register(ruleNAM002{})
e.Register(ruleNAM003{})
}
// Register adds a rule to the engine.
func (e *Engine) Register(r Rule) {
e.rules = append(e.rules, r)
}
// Rules returns a copy of the registered rule slice (for testing/inspection).
func (e *Engine) Rules() []Rule {
out := make([]Rule, len(e.rules))
copy(out, e.rules)
return out
}
// Check parses sql and runs all registered rules. Diagnostics are sorted by
// (line, col, ruleID). A parse error is returned as a single diagnostic with
// ruleID "PARSE".
func (e *Engine) Check(sql, file string) ([]diagnostics.Diagnostic, error) {
result, err := pgast.Parse(sql)
if err != nil {
line, col := parseErrLocation(err, sql)
return []diagnostics.Diagnostic{{
RuleID: "PARSE",
Severity: diagnostics.SeverityError,
Message: err.Error(),
File: file,
Line: line,
Col: col,
}}, nil
}
var all []diagnostics.Diagnostic
for _, r := range e.rules {
found := r.Check(result.Stmts, sql)
for i := range found {
found[i].File = file
}
all = append(all, found...)
}
sort.Slice(all, func(i, j int) bool {
if all[i].Line != all[j].Line {
return all[i].Line < all[j].Line
}
if all[i].Col != all[j].Col {
return all[i].Col < all[j].Col
}
return all[i].RuleID < all[j].RuleID
})
return all, nil
}
// parseErrLocation extracts a (line, col) from a pg_query parse error.
// The error message format is "syntax error at or near ... (location N)" where
// N is a 1-based character offset.
func parseErrLocation(err error, sql string) (line, col int) {
// pg_query errors expose Location via a type assertion.
type locErr interface{ GetCursorPos() int }
if le, ok := err.(locErr); ok {
pos := le.GetCursorPos() - 1 // convert to 0-based
return pgast.LocationToLineCol(sql, pos)
}
return 1, 1
}