This guide explains how to add a new lint rule to valk-guard.
Each rule implements the Rule interface in internal/rules/rule.go:
ID() string: unique ID likeVG009Name() string: machine-friendly nameDescription() string: human-friendly summaryDefaultSeverity() Severity:error,warning, orinfoCheck(ctx, ...) []Finding: rule logic
Rules run on parsed SQL (postgresparser.ParsedQuery) and return zero or more findings.
The context.Context is passed through for cancellation/timeouts; most rules can ignore it today.
Optional optimization interface:
CommandTargetedRulewithCommandTargets() []postgresparser.QueryCommand
If implemented, buildRulePlan(...) runs that rule only for matching command types. If omitted, the rule is treated as cross-cutting.
Add a new file under internal/rules/, for example:
internal/rules/vg009_no_select_distinct.go
Implement a struct with methods matching Rule.
import "context"
type NoSelectDistinctRule struct{}
func (r *NoSelectDistinctRule) ID() string { return "VG009" }
func (r *NoSelectDistinctRule) Name() string { return "no-select-distinct" }
func (r *NoSelectDistinctRule) Description() string { return "Detects SELECT DISTINCT usage." }
func (r *NoSelectDistinctRule) DefaultSeverity() Severity { return SeverityWarning }
func (r *NoSelectDistinctRule) CommandTargets() []postgresparser.QueryCommand {
return []postgresparser.QueryCommand{postgresparser.QueryCommandSelect}
}
func (r *NoSelectDistinctRule) Check(_ context.Context, parsed *postgresparser.ParsedQuery, file string, line int, rawSQL string) []Finding {
// rule logic here
return nil
}Register the rule in internal/rules/registry.go inside DefaultRegistry():
mustRegister(reg, &NoSelectDistinctRule{})Registration order controls output order when multiple rules fire.
Create tests in a dedicated file, for example:
internal/rules/vg009_no_select_distinct_test.go
Test both:
- Positive cases (finding expected)
- Negative cases (no finding expected)
Use real SQL strings parsed with postgresparser.ParseSQL.
Update:
README.mdrule table and examples.valk-guard.yaml.exampleif useful
If severity/enable defaults are important for users, document them explicitly.
Run:
go test ./...
go test -race ./...
go vet ./...If golangci-lint is available in your environment, run:
golangci-lint run ./...Schema-drift rules implement the SchemaRule interface instead of Rule:
type SchemaRule interface {
ID() string
Name() string
Description() string
DefaultSeverity() Severity
CheckSchema(ctx context.Context, snap *schema.Snapshot, models []schema.ModelDef) []Finding
}Schema rules cross-reference ORM model definitions (extracted from Go struct db tags or Python __tablename__/Column()) against migration DDL (parsed via postgresparser). They run after the per-statement phase.
- Create
internal/rules/vg1xx_your_rule.goimplementingSchemaRule. - Register in
DefaultRegistry()usingmustRegisterSchema(reg, &YourRule{}). - Schema rules receive a
*schema.Snapshot(accumulated DDL state) and[]schema.ModelDef(extracted models). - Use
matchTable(snap, modelTable)to resolve model table names against the snapshot (exact case-insensitive matching). - Respect model metadata in
schema.ModelDef:Sourceidentifies model engine (goorsqlalchemy).TableExplicitidentifies whether table mapping is explicit in source (for example__tablename__).TableMappingKind/TableMappingSourceidentify inferred vs explicit table mapping provenance.ModelColumn.MappingKind/MappingSourceidentify inferred vs explicit column mapping provenance.
See vg101_dropped_column.go, vg109_orphan_migration_table.go, and vg111_go_inferred_table_name_risk.go for reference implementations.
Query-schema rules compare parsed query column usage with schema snapshots selected by runtime (migration DDL and, when available, engine-matched model snapshots).
They implement QuerySchemaRule in internal/rules/query_schema_rule.go:
type QuerySchemaRule interface {
ID() string
Name() string
Description() string
DefaultSeverity() Severity
CheckQuerySchema(ctx context.Context, snap *schema.Snapshot, stmt *scanner.SQLStatement, parsed *postgresparser.ParsedQuery) []Finding
}- Create
internal/rules/vg10x_your_rule.goimplementingQuerySchemaRule. - Register in
DefaultRegistry()usingmustRegisterQuerySchema(reg, &YourRule{}). - Use parser metadata (
parsed.Tables,parsed.ColumnUsage) plus schema snapshot tables to resolve unknown columns. - Respect statement metadata in
scanner.SQLStatement:Enginefor per-rule engine scoping (sql,go,goqu,sqlalchemy)Disabledfor inline suppression directives
VG105-VG108 are reference implementations for projection, predicate, table-reference, and ambiguity checks across migration and model-derived snapshots.
- Keep checks deterministic and parser-driven, not regex-only where possible.
- Prefer low false-positive logic over aggressive detection.
- Set
Column: 1unless you have accurate column metadata. - Return concise, actionable finding messages.
- Avoid rule overlap unless intentionally complementary.