Skip to content

Commit a4b36f6

Browse files
Ajit Pratap SinghAjit Pratap Singh
authored andcommitted
feat: add SARIF output support for GitHub Code Scanning
- Implement SARIF 2.1.0 formatter in cmd/gosqlx/internal/output package - Add --output-format and --output-file flags to validate command - Support for three error types: syntax, parsing, and tokenization errors - Include fingerprinting for result deduplication - Cross-platform path normalization for SARIF URIs - Comprehensive test suite with 100% pass rate - Update action.yml with sarif-output input and upload step - Auto-enable quiet mode when outputting SARIF for clean JSON - Refactor ValidationResult types to avoid circular imports This enables GitHub Code Scanning integration for SQL validation errors, displaying them inline in pull requests and the Security tab. Related to issue #79
1 parent 779043c commit a4b36f6

8 files changed

Lines changed: 805 additions & 38 deletions

File tree

action.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ inputs:
5252
required: false
5353
default: 'false'
5454

55+
sarif-output:
56+
description: 'Generate SARIF output for GitHub Code Scanning (requires security-events: write permission)'
57+
required: false
58+
default: 'false'
59+
5560
gosqlx-version:
5661
description: 'GoSQLX version to use (default: latest)'
5762
required: false
@@ -297,6 +302,55 @@ runs:
297302
exit 1
298303
fi
299304
305+
- name: Generate SARIF output
306+
id: sarif
307+
if: inputs.sarif-output == 'true' && steps.find-files.outputs.file-count != '0'
308+
shell: bash
309+
working-directory: ${{ inputs.working-directory }}
310+
run: |
311+
echo "::group::Generate SARIF Report"
312+
313+
# Build validation command with SARIF output
314+
CMD="$HOME/go/bin/gosqlx validate --output-format sarif --output-file gosqlx-results.sarif"
315+
316+
# Add config if provided
317+
if [ -n "${{ inputs.config }}" ]; then
318+
if [ -f "${{ inputs.config }}" ]; then
319+
export GOSQLX_CONFIG="${{ inputs.config }}"
320+
fi
321+
fi
322+
323+
# Add dialect if provided
324+
DIALECT="${{ inputs.dialect }}"
325+
if [ -n "$DIALECT" ] && [[ "$DIALECT" =~ ^(postgresql|mysql|sqlserver|oracle|sqlite)$ ]]; then
326+
CMD="$CMD --dialect $DIALECT"
327+
fi
328+
329+
# Add strict mode if enabled
330+
if [ "${{ inputs.strict }}" = "true" ]; then
331+
CMD="$CMD --strict"
332+
fi
333+
334+
# Read files and run validation to generate SARIF
335+
cat /tmp/gosqlx-files.txt | tr '\n' ' ' | xargs $CMD || true
336+
337+
# Check if SARIF file was created
338+
if [ -f "gosqlx-results.sarif" ]; then
339+
echo "✓ SARIF report generated: gosqlx-results.sarif"
340+
echo "sarif-file=gosqlx-results.sarif" >> $GITHUB_OUTPUT
341+
else
342+
echo "::warning::SARIF report generation failed"
343+
fi
344+
345+
echo "::endgroup::"
346+
347+
- name: Upload SARIF to GitHub Code Scanning
348+
if: inputs.sarif-output == 'true' && steps.sarif.outputs.sarif-file != ''
349+
uses: github/codeql-action/upload-sarif@v3
350+
with:
351+
sarif_file: ${{ inputs.working-directory }}/gosqlx-results.sarif
352+
category: gosqlx-sql-validation
353+
300354
- name: Check SQL formatting
301355
id: format-check
302356
if: inputs.format-check == 'true' && steps.find-files.outputs.file-count != '0'

cmd/gosqlx/cmd/format.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ func formatRun(cmd *cobra.Command, args []string) error {
6767
Check: formatCheck,
6868
MaxLine: formatMaxLine,
6969
Verbose: verbose,
70-
Output: output,
70+
Output: outputFile,
7171
})
7272

7373
// Create formatter with injectable output writers

cmd/gosqlx/cmd/root.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ import (
44
"github.com/spf13/cobra"
55
)
66

7+
// Version is the current version of gosqlx CLI
8+
var Version = "1.4.0"
9+
710
var (
811
// Global flags
9-
verbose bool
10-
output string
11-
format string
12+
verbose bool
13+
outputFile string
14+
format string
1215
)
1316

1417
// rootCmd represents the base command when called without any subcommands
@@ -42,6 +45,6 @@ func Execute() error {
4245
func init() {
4346
// Global flags
4447
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "enable verbose output")
45-
rootCmd.PersistentFlags().StringVarP(&output, "output", "o", "", "output file (default: stdout)")
48+
rootCmd.PersistentFlags().StringVarP(&outputFile, "output", "o", "", "output file (default: stdout)")
4649
rootCmd.PersistentFlags().StringVarP(&format, "format", "f", "auto", "output format: json, yaml, table, tree, auto")
4750
}

cmd/gosqlx/cmd/validate.go

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
11
package cmd
22

33
import (
4+
"fmt"
45
"os"
56

67
"github.com/spf13/cobra"
78
"github.com/spf13/pflag"
89

910
"github.com/ajitpratap0/GoSQLX/cmd/gosqlx/internal/config"
11+
"github.com/ajitpratap0/GoSQLX/cmd/gosqlx/internal/output"
1012
)
1113

1214
var (
13-
validateRecursive bool
14-
validatePattern string
15-
validateQuiet bool
16-
validateStats bool
17-
validateDialect string
18-
validateStrict bool
15+
validateRecursive bool
16+
validatePattern string
17+
validateQuiet bool
18+
validateStats bool
19+
validateDialect string
20+
validateStrict bool
21+
validateOutputFormat string
22+
validateOutputFile string
1923
)
2024

2125
// validateCmd represents the validate command
@@ -31,6 +35,12 @@ Examples:
3135
gosqlx validate -r ./queries/ # Recursively validate directory
3236
gosqlx validate --quiet query.sql # Quiet mode (exit code only)
3337
gosqlx validate --stats ./queries/ # Show performance statistics
38+
gosqlx validate --output-format sarif --output-file results.sarif queries/ # SARIF output for GitHub Code Scanning
39+
40+
Output Formats:
41+
text - Human-readable output (default)
42+
json - JSON format for programmatic consumption
43+
sarif - SARIF 2.1.0 format for GitHub Code Scanning integration
3444
3545
Performance Target: <10ms for typical queries (50-500 characters)
3646
Throughput: 100+ files/second in batch mode`,
@@ -46,6 +56,11 @@ func validateRun(cmd *cobra.Command, args []string) error {
4656
cfg = config.DefaultConfig()
4757
}
4858

59+
// Validate output format
60+
if validateOutputFormat != "" && validateOutputFormat != "text" && validateOutputFormat != "json" && validateOutputFormat != "sarif" {
61+
return fmt.Errorf("invalid output format: %s (valid options: text, json, sarif)", validateOutputFormat)
62+
}
63+
4964
// Track which flags were explicitly set
5065
flagsChanged := make(map[string]bool)
5166
cmd.Flags().Visit(func(f *pflag.Flag) {
@@ -58,10 +73,13 @@ func validateRun(cmd *cobra.Command, args []string) error {
5873
}
5974

6075
// Create validator options from config and flags
76+
// When outputting SARIF, automatically enable quiet mode to avoid mixing output
77+
quietMode := validateQuiet || validateOutputFormat == "sarif"
78+
6179
opts := ValidatorOptionsFromConfig(cfg, flagsChanged, ValidatorFlags{
6280
Recursive: validateRecursive,
6381
Pattern: validatePattern,
64-
Quiet: validateQuiet,
82+
Quiet: quietMode,
6583
ShowStats: validateStats,
6684
Dialect: validateDialect,
6785
StrictMode: validateStrict,
@@ -77,6 +95,31 @@ func validateRun(cmd *cobra.Command, args []string) error {
7795
return err
7896
}
7997

98+
// Handle different output formats
99+
if validateOutputFormat == "sarif" {
100+
// Generate SARIF output
101+
sarifData, err := output.FormatSARIF(result, Version)
102+
if err != nil {
103+
return fmt.Errorf("failed to generate SARIF output: %w", err)
104+
}
105+
106+
// Write SARIF output to file or stdout
107+
if validateOutputFile != "" {
108+
if err := os.WriteFile(validateOutputFile, sarifData, 0644); err != nil {
109+
return fmt.Errorf("failed to write SARIF output: %w", err)
110+
}
111+
if !opts.Quiet {
112+
fmt.Fprintf(cmd.OutOrStdout(), "SARIF output written to %s\n", validateOutputFile)
113+
}
114+
} else {
115+
fmt.Fprint(cmd.OutOrStdout(), string(sarifData))
116+
}
117+
} else if validateOutputFormat == "json" {
118+
// JSON output format will be implemented later
119+
return fmt.Errorf("JSON output format not yet implemented")
120+
}
121+
// Default text output is already handled by the validator
122+
80123
// Exit with error code if there were invalid files
81124
if result.InvalidFiles > 0 {
82125
os.Exit(1)
@@ -94,4 +137,6 @@ func init() {
94137
validateCmd.Flags().BoolVarP(&validateStats, "stats", "s", false, "show performance statistics")
95138
validateCmd.Flags().StringVar(&validateDialect, "dialect", "", "SQL dialect: postgresql, mysql, sqlserver, oracle, sqlite (config: validate.dialect)")
96139
validateCmd.Flags().BoolVar(&validateStrict, "strict", false, "enable strict validation mode (config: validate.strict_mode)")
140+
validateCmd.Flags().StringVar(&validateOutputFormat, "output-format", "text", "output format: text, json, sarif")
141+
validateCmd.Flags().StringVar(&validateOutputFile, "output-file", "", "output file path (default: stdout)")
97142
}

cmd/gosqlx/cmd/validator.go

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"time"
1010

1111
"github.com/ajitpratap0/GoSQLX/cmd/gosqlx/internal/config"
12+
"github.com/ajitpratap0/GoSQLX/cmd/gosqlx/internal/output"
1213
"github.com/ajitpratap0/GoSQLX/pkg/sql/ast"
1314
"github.com/ajitpratap0/GoSQLX/pkg/sql/parser"
1415
"github.com/ajitpratap0/GoSQLX/pkg/sql/tokenizer"
@@ -32,24 +33,6 @@ type Validator struct {
3233
Opts ValidatorOptions
3334
}
3435

35-
// ValidationResult contains the results of a validation run
36-
type ValidationResult struct {
37-
TotalFiles int
38-
ValidFiles int
39-
InvalidFiles int
40-
TotalBytes int64
41-
Duration time.Duration
42-
Files []FileValidationResult
43-
}
44-
45-
// FileValidationResult contains the result for a single file
46-
type FileValidationResult struct {
47-
Path string
48-
Valid bool
49-
Size int64
50-
Error error
51-
}
52-
5336
// NewValidator creates a new Validator with the given options
5437
func NewValidator(out, err io.Writer, opts ValidatorOptions) *Validator {
5538
return &Validator{
@@ -60,7 +43,7 @@ func NewValidator(out, err io.Writer, opts ValidatorOptions) *Validator {
6043
}
6144

6245
// Validate validates the given SQL files or patterns
63-
func (v *Validator) Validate(args []string) (*ValidationResult, error) {
46+
func (v *Validator) Validate(args []string) (*output.ValidationResult, error) {
6447
startTime := time.Now()
6548

6649
// Expand file arguments (glob patterns, directories, etc.)
@@ -73,8 +56,8 @@ func (v *Validator) Validate(args []string) (*ValidationResult, error) {
7356
return nil, fmt.Errorf("no SQL files found matching the specified patterns")
7457
}
7558

76-
result := &ValidationResult{
77-
Files: make([]FileValidationResult, 0, len(files)),
59+
result := &output.ValidationResult{
60+
Files: make([]output.FileValidationResult, 0, len(files)),
7861
}
7962

8063
// Validate each file
@@ -116,8 +99,8 @@ func (v *Validator) Validate(args []string) (*ValidationResult, error) {
11699
}
117100

118101
// validateFile validates a single SQL file
119-
func (v *Validator) validateFile(filename string) FileValidationResult {
120-
result := FileValidationResult{
102+
func (v *Validator) validateFile(filename string) output.FileValidationResult {
103+
result := output.FileValidationResult{
121104
Path: filename,
122105
}
123106

@@ -235,7 +218,7 @@ func (v *Validator) isDirectory(path string) bool {
235218
}
236219

237220
// displayStats displays validation statistics
238-
func (v *Validator) displayStats(result *ValidationResult) {
221+
func (v *Validator) displayStats(result *output.ValidationResult) {
239222
fmt.Fprintf(v.Out, "\n📊 Validation Statistics:\n")
240223
fmt.Fprintf(v.Out, " Files processed: %d\n", result.TotalFiles)
241224
fmt.Fprintf(v.Out, " Valid files: %d\n", result.ValidFiles)
@@ -296,7 +279,8 @@ func ValidatorOptionsFromConfig(cfg *config.Config, flagsChanged map[string]bool
296279
if flagsChanged["pattern"] {
297280
opts.Pattern = flags.Pattern
298281
}
299-
if flagsChanged["quiet"] {
282+
// Always use quiet flag value (may be set programmatically for SARIF output)
283+
if flagsChanged["quiet"] || flags.Quiet {
300284
opts.Quiet = flags.Quiet
301285
}
302286
if flagsChanged["stats"] {

cmd/gosqlx/cmd/validator_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"runtime"
88
"strings"
99
"testing"
10+
11+
"github.com/ajitpratap0/GoSQLX/cmd/gosqlx/internal/output"
1012
)
1113

1214
// TestValidator_ValidateFile tests single file validation
@@ -394,7 +396,7 @@ func TestValidator_DisplayStats(t *testing.T) {
394396
var buf bytes.Buffer
395397
validator := NewValidator(&buf, &buf, ValidatorOptions{})
396398

397-
result := &ValidationResult{
399+
result := &output.ValidationResult{
398400
TotalFiles: 10,
399401
ValidFiles: 8,
400402
InvalidFiles: 2,

0 commit comments

Comments
 (0)