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
+146
View File
@@ -0,0 +1,146 @@
package main
import (
"fmt"
"io"
"os"
"strings"
"github.com/hein/pgtidy/pkg/diagnostics"
"github.com/hein/pgtidy/pkg/lint"
)
// cmdLint implements `pgtidy lint`. Reads one or more SQL files (or stdin) and
// reports lint findings to stdout. Exits non-zero when any findings are found.
//
// Output format (one line per finding):
//
// file:line:col: [RULEID] severity: message
func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
var (
only []string // --only=ID,ID rule filter
files []string
)
for _, a := range args {
switch {
case a == "-h" || a == "--help":
lintUsage(stdout)
return 0
case strings.HasPrefix(a, "--only="):
ids := strings.Split(strings.TrimPrefix(a, "--only="), ",")
for _, id := range ids {
if id = strings.TrimSpace(id); id != "" {
only = append(only, strings.ToUpper(id))
}
}
case len(a) > 1 && a[0] == '-':
fmt.Fprintf(stderr, "pgtidy lint: unknown flag %q\n", a)
return 2
default:
files = append(files, a)
}
}
eng := lint.New()
check := func(sql, file string) ([]diagnostics.Diagnostic, error) {
diags, err := eng.Check(sql, file)
if err != nil {
return nil, err
}
if len(only) > 0 {
filtered := diags[:0]
for _, d := range diags {
for _, id := range only {
if d.RuleID == id {
filtered = append(filtered, d)
break
}
}
}
diags = filtered
}
return diags, nil
}
printDiags := func(diags []diagnostics.Diagnostic) {
for _, d := range diags {
loc := d.File
if d.Line > 0 {
loc = fmt.Sprintf("%s:%d:%d", d.File, d.Line, d.Col)
}
if loc == "" {
loc = "stdin"
}
fmt.Fprintf(stdout, "%s: [%s] %s: %s\n", loc, d.RuleID, d.Severity, d.Message)
}
}
found := false
if len(files) == 0 {
src, err := io.ReadAll(stdin)
if err != nil {
fmt.Fprintf(stderr, "pgtidy: reading stdin: %v\n", err)
return 2
}
diags, err := check(string(src), "")
if err != nil {
fmt.Fprintf(stderr, "pgtidy: %v\n", err)
return 2
}
printDiags(diags)
if len(diags) > 0 {
found = true
}
} else {
for _, path := range files {
src, err := os.ReadFile(path)
if err != nil {
fmt.Fprintf(stderr, "pgtidy: %v\n", err)
return 2
}
diags, err := check(string(src), path)
if err != nil {
fmt.Fprintf(stderr, "pgtidy: %v\n", err)
return 2
}
printDiags(diags)
if len(diags) > 0 {
found = true
}
}
}
if found {
return 1
}
return 0
}
func lintUsage(w io.Writer) {
fmt.Fprint(w, `Usage: pgtidy lint [flags] [file ...]
Read SQL from files (or stdin) and report lint findings.
Flags:
--only=ID,... comma-separated rule IDs to enable (default: all rules)
-h, --help show this help
Rules:
MIG001 CREATE INDEX without CONCURRENTLY blocks writes
MIG002 ALTER TABLE ADD COLUMN NOT NULL with no DEFAULT may rewrite table
MIG003 ALTER TABLE ADD CONSTRAINT without NOT VALID validates all rows
COR001 SELECT * is fragile; enumerate columns explicitly
COR002 UPDATE without WHERE modifies every row
COR003 DELETE without WHERE removes every row
NAM001 Table name is not snake_case
NAM002 Column name is not snake_case
NAM003 Function/procedure name is not snake_case
Exit codes:
0 no findings
1 one or more findings
2 error (file not found, parse error, bad flag)
`)
}
+13 -5
View File
@@ -1,5 +1,4 @@
// Command pgtidy is the PgTidy CLI: a PostgreSQL formatter (and, later, linter)
// and LSP server. Today it provides the `fmt` subcommand.
// Command pgtidy is the PgTidy CLI: a PostgreSQL formatter and linter.
package main
import (
@@ -23,6 +22,8 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
switch args[0] {
case "fmt", "format":
return cmdFmt(args[1:], stdin, stdout, stderr)
case "lint":
return cmdLint(args[1:], stdin, stdout, stderr)
case "version", "--version", "-v":
fmt.Fprintf(stdout, "pgtidy %s\n", version)
return 0
@@ -40,13 +41,20 @@ func usage(w io.Writer) {
fmt.Fprint(w, `pgtidy — PostgreSQL formatter and linter
Usage:
pgtidy fmt [flags] [files...] Format SQL/PL-pgSQL (stdin if no files)
pgtidy version Print version
pgtidy help Show this help
pgtidy fmt [flags] [files...] Format SQL/PL-pgSQL (stdin if no files)
pgtidy lint [flags] [files...] Lint SQL (stdin if no files)
pgtidy version Print version
pgtidy help Show this help
fmt flags:
-w, --write Rewrite files in place
-l, --list List files whose formatting differs (no writes)
-d, --diff Print unified diff of changes
--check Exit non-zero if any input is not already formatted (CI)
lint flags:
--only=ID,... Enable only the specified rule IDs (comma-separated)
Run 'pgtidy lint --help' for the full rule list.
`)
}