Skip to content

Commit be53bcd

Browse files
ajitpratap0Ajit Pratap Singhclaude
authored
feat(linter): expand linter from 10 to 30 rules — safety, performance, naming (#445)
* docs: add Q2 2026 roadmap design spec from multi-persona audit Full-project audit using 5 parallel analytical personas (Performance, SQL Compatibility, API/DX, Competitive, Community). Synthesizes into prioritized P0–P3 roadmap covering: HN launch, query fingerprinting, linter expansion to 30 rules, DML transforms, C binding hardening, live DB schema introspection, SQL transpilation, CONNECT BY, OTel, GORM integration, and advisor expansion. Corresponding GitHub issues: #442#460 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add Q2 2026 implementation plans for all roadmap items 12 implementation plans covering all prioritized GitHub issues: P0 (Critical): - 2026-03-29-sentry-fixes.md (#434, #437) — fix Sentry noise filters - 2026-03-29-openssf-scorecard.md (#443) — security scorecard badge P1 (High Priority): - 2026-03-29-query-fingerprinting.md (#444) — SQL normalization + SHA-256 fingerprints - 2026-03-29-linter-expansion.md (#445) — L011-L030 safety/performance/naming rules - 2026-03-29-dml-transforms.md (#446) — SET clause and RETURNING transforms - 2026-03-29-cbinding-hardening.md (#447) — C binding coverage + stress tests - 2026-03-29-advisor-expansion.md (#453) — OPT-009 through OPT-020 advisor rules P2 (Medium Priority): - 2026-03-29-sql-parser-additions.md (#450, #454, #455, #456) — DDL formatter, CONNECT BY, SAMPLE, PIVOT/UNPIVOT - 2026-03-29-schema-introspection.md (#448) — live DB schema introspection (Postgres, MySQL, SQLite) - 2026-03-29-integrations.md (#451, #452) — OpenTelemetry + GORM sub-modules - 2026-03-29-sql-transpilation.md (#449) — SQL dialect transpilation API P3 (Low Priority): - 2026-03-29-p3-items.md (#458, #459, #460) — CLI watch registration, pool stats, JSON functions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(linter): add safety (L011-L015), performance (L016-L023), naming (L024-L030) rules (#445) Expand linter from 10 to 30 rules across three new sub-packages: Safety (L011-L015): - L011: DELETE without WHERE (error) - L012: UPDATE without WHERE (error) - L013: DROP without IF EXISTS (warning) - L014: TRUNCATE TABLE warning - L015: SELECT INTO OUTFILE/DUMPFILE (error, text-level) Performance (L016-L023): - L016: SELECT * (warning) - L017: Missing WHERE on full-table SELECT (warning) - L018: Leading wildcard LIKE '%...' (warning) - L019: NOT IN (subquery) NULL risk (warning) - L020: Correlated subquery in SELECT list / N+1 (warning) - L021: OR instead of IN for same column (warning) - L022: Function on indexed column in WHERE (warning) - L023: Implicit cross join via comma tables (warning) Naming/style (L024-L030): - L024: Table alias required in multi-table queries (warning) - L025: Reserved keyword used as identifier (warning) - L026: Implicit column list in INSERT (warning) - L027: UNION instead of UNION ALL (warning) - L028: LIMIT without ORDER BY (warning) - L029: EXISTS/IN subquery can be a JOIN (warning) - L030: DISTINCT on many columns (warning) All rules: AST-based where possible, visitor pattern for deep traversal, no auto-fix for destructive/semantic changes, TDD with violation and no-violation test cases, race-detector clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(linter): address PR #467 review feedback - Add DropSequenceStatement case arm to L013 (drop_without_condition) - Extend function_on_column visitor to traverse HAVING clauses and CTEs - Remove unnecessary join := join copy in range loop - Use neutral location instead of misleading sel.Pos in reserved_keyword_identifier - Keep OR-to-IN threshold at 3 (consistent with existing tests) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Ajit Pratap Singh <ajitpratapsingh@Ajits-Mac-mini-2655.local> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 43ccdf5 commit be53bcd

27 files changed

+2939
-0
lines changed

pkg/linter/rule.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,16 +229,41 @@ func (r BaseRule) CanAutoFix() bool {
229229
// Use this to validate that user-specified rule names in configuration
230230
// files (e.g., .gosqlx.yml) reference actual rules.
231231
var ValidRuleIDs = map[string]string{
232+
// Whitespace rules
232233
"L001": "Trailing Whitespace",
233234
"L002": "Mixed Indentation",
234235
"L003": "Consecutive Blank Lines",
235236
"L004": "Indentation Depth",
236237
"L005": "Long Lines",
237238
"L006": "Column Alignment",
239+
// Style rules
238240
"L007": "Keyword Case Consistency",
239241
"L008": "Comma Placement",
240242
"L009": "Aliasing Consistency",
241243
"L010": "Redundant Whitespace",
244+
// Safety rules
245+
"L011": "Delete Without WHERE",
246+
"L012": "Update Without WHERE",
247+
"L013": "Drop Without IF EXISTS",
248+
"L014": "Truncate Table",
249+
"L015": "Select Into Outfile",
250+
// Performance rules
251+
"L016": "Select Star",
252+
"L017": "Missing WHERE on Full Scan",
253+
"L018": "Leading Wildcard LIKE",
254+
"L019": "NOT IN With NULL Risk",
255+
"L020": "Correlated Subquery in SELECT",
256+
"L021": "OR Instead of IN",
257+
"L022": "Function on Indexed Column",
258+
"L023": "Implicit Cross Join",
259+
// Naming/style rules
260+
"L024": "Table Alias Required",
261+
"L025": "Reserved Keyword Identifier",
262+
"L026": "Implicit Column List in INSERT",
263+
"L027": "UNION Instead of UNION ALL",
264+
"L028": "Missing ORDER BY with LIMIT",
265+
"L029": "Subquery Can Be JOIN",
266+
"L030": "Distinct on Many Columns",
242267
}
243268

244269
// IsValidRuleID checks whether a rule ID corresponds to an implemented rule.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright 2026 GoSQLX Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package naming
16+
17+
import (
18+
"fmt"
19+
20+
"github.com/ajitpratap0/GoSQLX/pkg/linter"
21+
"github.com/ajitpratap0/GoSQLX/pkg/sql/ast"
22+
)
23+
24+
const distinctColumnThreshold = 5
25+
26+
// DistinctOnManyColumnsRule (L030) warns when DISTINCT is used with many columns.
27+
// DISTINCT on many columns is often a sign of a missing GROUP BY or denormalized
28+
// data. It also forces a sort over all projected columns, which is expensive.
29+
type DistinctOnManyColumnsRule struct{ linter.BaseRule }
30+
31+
// NewDistinctOnManyColumnsRule creates a new L030 rule instance.
32+
func NewDistinctOnManyColumnsRule() *DistinctOnManyColumnsRule {
33+
return &DistinctOnManyColumnsRule{
34+
BaseRule: linter.NewBaseRule(
35+
"L030",
36+
"Distinct on Many Columns",
37+
"DISTINCT on many columns suggests a missing GROUP BY or data quality issue",
38+
linter.SeverityWarning,
39+
false,
40+
),
41+
}
42+
}
43+
44+
// Check inspects SELECT statements for DISTINCT with many columns.
45+
func (r *DistinctOnManyColumnsRule) Check(ctx *linter.Context) ([]linter.Violation, error) {
46+
if ctx.AST == nil {
47+
return nil, nil
48+
}
49+
var violations []linter.Violation
50+
for _, stmt := range ctx.AST.Statements {
51+
sel, ok := stmt.(*ast.SelectStatement)
52+
if !ok {
53+
continue
54+
}
55+
if !sel.Distinct {
56+
continue
57+
}
58+
colCount := len(sel.Columns)
59+
if colCount >= distinctColumnThreshold {
60+
violations = append(violations, linter.Violation{
61+
Rule: r.ID(),
62+
RuleName: r.Name(),
63+
Severity: r.Severity(),
64+
Message: fmt.Sprintf("DISTINCT on %d columns is expensive and may indicate a missing GROUP BY or join issue", colCount),
65+
Location: sel.Pos,
66+
Suggestion: "Consider using GROUP BY with aggregate functions, or investigate whether the query structure can be simplified",
67+
})
68+
}
69+
}
70+
return violations, nil
71+
}
72+
73+
// Fix is a no-op: replacing DISTINCT with GROUP BY requires semantic understanding.
74+
func (r *DistinctOnManyColumnsRule) Fix(content string, violations []linter.Violation) (string, error) {
75+
return content, nil
76+
}

pkg/linter/rules/naming/doc.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright 2026 GoSQLX Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package naming provides linter rules for SQL naming conventions and style.
16+
//
17+
// Rules:
18+
// - L024: Table alias required (multi-table queries)
19+
// - L025: Reserved keyword used as identifier
20+
// - L026: Implicit column list in INSERT
21+
// - L027: UNION instead of UNION ALL
22+
// - L028: Missing ORDER BY with LIMIT
23+
// - L029: Subquery in WHERE can be a JOIN
24+
// - L030: DISTINCT on many columns
25+
package naming
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright 2026 GoSQLX Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package naming
16+
17+
import (
18+
"github.com/ajitpratap0/GoSQLX/pkg/linter"
19+
"github.com/ajitpratap0/GoSQLX/pkg/sql/ast"
20+
)
21+
22+
// ImplicitColumnListRule (L026) flags INSERT statements without an explicit column list.
23+
// INSERT INTO table VALUES (...) is fragile — it breaks when columns are added/reordered.
24+
type ImplicitColumnListRule struct{ linter.BaseRule }
25+
26+
// NewImplicitColumnListRule creates a new L026 rule instance.
27+
func NewImplicitColumnListRule() *ImplicitColumnListRule {
28+
return &ImplicitColumnListRule{
29+
BaseRule: linter.NewBaseRule(
30+
"L026",
31+
"Implicit Column List in INSERT",
32+
"INSERT without explicit column list is fragile and breaks on schema changes",
33+
linter.SeverityWarning,
34+
false,
35+
),
36+
}
37+
}
38+
39+
// Check inspects INSERT statements for missing column lists.
40+
func (r *ImplicitColumnListRule) Check(ctx *linter.Context) ([]linter.Violation, error) {
41+
if ctx.AST == nil {
42+
return nil, nil
43+
}
44+
var violations []linter.Violation
45+
for _, stmt := range ctx.AST.Statements {
46+
ins, ok := stmt.(*ast.InsertStatement)
47+
if !ok {
48+
continue
49+
}
50+
// If there are VALUES but no explicit column list, flag it
51+
if len(ins.Values) > 0 && len(ins.Columns) == 0 {
52+
violations = append(violations, linter.Violation{
53+
Rule: r.ID(),
54+
RuleName: r.Name(),
55+
Severity: r.Severity(),
56+
Message: "INSERT INTO " + ins.TableName + " has no explicit column list",
57+
Location: ins.Pos,
58+
Suggestion: "Specify columns explicitly: INSERT INTO " + ins.TableName + " (col1, col2, ...) VALUES (...)",
59+
})
60+
}
61+
}
62+
return violations, nil
63+
}
64+
65+
// Fix is a no-op: adding column list requires schema knowledge.
66+
func (r *ImplicitColumnListRule) Fix(content string, violations []linter.Violation) (string, error) {
67+
return content, nil
68+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright 2026 GoSQLX Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package naming
16+
17+
import (
18+
"github.com/ajitpratap0/GoSQLX/pkg/linter"
19+
"github.com/ajitpratap0/GoSQLX/pkg/sql/ast"
20+
)
21+
22+
// MissingOrderByLimitRule (L028) flags queries that use LIMIT/OFFSET without ORDER BY.
23+
// Without ORDER BY, the rows returned by LIMIT are non-deterministic — different
24+
// executions may return different rows, making pagination unreliable.
25+
type MissingOrderByLimitRule struct{ linter.BaseRule }
26+
27+
// NewMissingOrderByLimitRule creates a new L028 rule instance.
28+
func NewMissingOrderByLimitRule() *MissingOrderByLimitRule {
29+
return &MissingOrderByLimitRule{
30+
BaseRule: linter.NewBaseRule(
31+
"L028",
32+
"Missing ORDER BY with LIMIT",
33+
"LIMIT without ORDER BY produces non-deterministic results",
34+
linter.SeverityWarning,
35+
false,
36+
),
37+
}
38+
}
39+
40+
// Check inspects SELECT statements for LIMIT/OFFSET without ORDER BY.
41+
func (r *MissingOrderByLimitRule) Check(ctx *linter.Context) ([]linter.Violation, error) {
42+
if ctx.AST == nil {
43+
return nil, nil
44+
}
45+
var violations []linter.Violation
46+
for _, stmt := range ctx.AST.Statements {
47+
sel, ok := stmt.(*ast.SelectStatement)
48+
if !ok {
49+
continue
50+
}
51+
hasLimit := sel.Limit != nil || sel.Fetch != nil
52+
if !hasLimit {
53+
continue
54+
}
55+
hasOffset := sel.Offset != nil || (sel.Fetch != nil && sel.Fetch.OffsetValue != nil)
56+
hasOrderBy := len(sel.OrderBy) > 0
57+
if !hasOrderBy {
58+
msg := "LIMIT without ORDER BY produces non-deterministic results"
59+
if hasOffset {
60+
msg = "LIMIT/OFFSET without ORDER BY produces non-deterministic pagination"
61+
}
62+
violations = append(violations, linter.Violation{
63+
Rule: r.ID(),
64+
RuleName: r.Name(),
65+
Severity: r.Severity(),
66+
Message: msg,
67+
Location: sel.Pos,
68+
Suggestion: "Add ORDER BY to ensure deterministic row selection with LIMIT",
69+
})
70+
}
71+
}
72+
return violations, nil
73+
}
74+
75+
// Fix is a no-op: choosing the right ORDER BY requires business logic.
76+
func (r *MissingOrderByLimitRule) Fix(content string, violations []linter.Violation) (string, error) {
77+
return content, nil
78+
}

0 commit comments

Comments
 (0)