Files
PgTidy/cmd/pgtidy/lint.go
T
warkanum 7fb76bae3d 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.
2026-06-27 22:15:02 +02:00

147 lines
3.3 KiB
Go

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)
`)
}