feat: replace go-sarif with internal SARIF implementation (#113)

* docs: add design for replacing go-sarif with internal implementation

Removes external dependency chain (go-sarif → testify → yaml.v3) by
implementing a minimal internal SARIF package with support for v2.1.0
and v3.0 output formats.

* docs: add implementation plan for replacing go-sarif

11 tasks covering:
- testjson parser with all 7 fields
- internal SARIF model and version registry
- v2.1.0 and v2.2 serializers
- converter refactoring
- CLI with --sarif-version and --pretty flags
- dependency cleanup

* feat(testjson): add parser for go test -json output

Add TestEvent struct with all 7 fields from go test -json and
ParseFile function to parse test output files line by line.

* test(testjson): add tests for all fields, malformed JSON, file not found

* feat(sarif): add internal SARIF data model

* feat(sarif): add version registry (serializers pending)

Add version registry infrastructure for SARIF serialization:
- Version type and constants (2.1.0, 2.2)
- DefaultVersion set to 2.1.0
- Register() function for version-specific serializers
- Serialize() function with pretty-print support
- SupportedVersions() for listing registered versions

Note: TestSupportedVersions will fail until serializers are
registered via init() functions in v21.go and v22.go (Tasks 5/6).

* feat(sarif): add SARIF v2.1.0 serializer

* feat(sarif): add SARIF v2.2 serializer

* test(sarif): add pretty print test

* refactor(converter): use internal sarif and testjson packages

Replace external go-sarif library with internal packages:
- Use internal/sarif for SARIF model and serialization
- Use internal/testjson for Go test JSON parsing
- Add ConvertOptions struct with SARIFVersion and Pretty options
- Remove external github.com/owenrumney/go-sarif/v2 dependency

* feat(cli): add --sarif-version and --pretty flags

* fix(lint): resolve golangci-lint errors

Fix errcheck violation by properly handling f.Close() error return value
and add proper package comments to satisfy revive linter.

* refactor: consolidate SARIF serializers and fix code issues

- Extract shared SARIF types and serialization logic into serialize.go
- Simplify v21.go and v22.go to thin wrappers (107/108 → 12 lines each)
- Add testConvertHelper() to reduce test duplication in converter_test.go
- Remove redundant nested check in converter.go (outer condition already guarantees non-empty)
- Fix LogicalLocations: avoid leading dot when Module is empty, set Kind correctly
- Increase scanner buffer in parser.go from 64KB to 4MB for large JSON lines
- Extract test constants to reduce string literal duplication

* docs: fix SARIF v3.0 references to v2.2

SARIF v3.0 doesn't exist. Update design and implementation docs
to correctly reference v2.1.0 and v2.2 throughout.

* fix: update SARIF 2.2 schema URL and add markdown language identifiers

Update schema URL to point to accessible prerelease location and add
language identifiers to fenced code blocks to resolve MD040 violations.

* chore: add pre-commit config and fix config file formatting

Add pre-commit configuration and fix formatting issues in config files
including trailing whitespace, YAML document markers, and JSON formatting.

* docs: fix markdown linting issues in implementation plan

- Convert bold step markers to proper headings
- Fix line length violations in header section
- Add markdownlint config to allow duplicate sibling headings
- Add ecrc config to exclude plan docs from editorconfig checking

* chore: update MegaLinter configuration

Configure specific linters and add exclusion patterns for test data
and generated files.

* docs: fix filename in design doc (writer.go → serialize.go)

* refactor(tests): fix SonarCloud code quality issues

Extract helper functions to reduce cognitive complexity in TestRun and
consolidate duplicate string literals into shared testutil package.

* docs: add docstrings to exported variables and struct fields

Add documentation comments to reach ~98% docstring coverage:
- cmd/main.go: document build-time variables
- internal/converter.go: document ConvertOptions fields
- internal/sarif/model.go: document Report, Rule, Result, LogicalLocation fields
- internal/testjson/parser.go: document TestEvent fields

* fix(testjson): change FailedBuild field type from string to bool

The go test -json output emits FailedBuild as a boolean, not a string.
This was causing JSON unmarshal errors. Updated the struct field type
and corresponding test assertions.

* chore(ci): mega-linter config tweaks for revive
This commit is contained in:
2026-01-21 02:16:05 +02:00
committed by GitHub
parent a6e7936fbd
commit e7ad11395a
28 changed files with 3157 additions and 220 deletions

View File

@@ -2,61 +2,79 @@
package internal
import (
"bufio"
"encoding/json"
"fmt"
"os"
"github.com/owenrumney/go-sarif/v2/sarif"
"github.com/ivuorinen/go-test-sarif-action/internal/sarif"
"github.com/ivuorinen/go-test-sarif-action/internal/testjson"
)
// TestEvent represents a single line of `go test -json` output.
type TestEvent struct {
Action string `json:"Action"`
Package string `json:"Package"`
Test string `json:"Test,omitempty"`
Output string `json:"Output,omitempty"`
// ConvertOptions configures the conversion behavior.
type ConvertOptions struct {
// SARIFVersion specifies which SARIF schema version to use.
SARIFVersion sarif.Version
// Pretty enables indented JSON output for readability.
Pretty bool
}
// ConvertToSARIF converts Go test JSON events to the SARIF format.
func ConvertToSARIF(inputFile, outputFile string) error {
f, err := os.Open(inputFile)
if err != nil {
return err
// DefaultConvertOptions returns options with sensible defaults.
func DefaultConvertOptions() ConvertOptions {
return ConvertOptions{
SARIFVersion: sarif.DefaultVersion,
Pretty: false,
}
defer func() { _ = f.Close() }()
}
report, err := sarif.New(sarif.Version210)
// ConvertToSARIF converts Go test JSON events to SARIF format.
func ConvertToSARIF(inputFile, outputFile string, opts ConvertOptions) error {
// Parse go test JSON
events, err := testjson.ParseFile(inputFile)
if err != nil {
return err
}
run := sarif.NewRunWithInformationURI("go-test-sarif", "https://golang.org/cmd/go/#hdr-Test_packages")
rule := run.AddRule("go-test-failure").WithDescription("go test failure")
// Build internal SARIF model
report := buildReport(events)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
var event TestEvent
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
return fmt.Errorf("invalid JSON: %w", err)
}
if event.Action == "fail" && (event.Test != "" || event.Package != "") {
result := sarif.NewRuleResult(rule.ID).
WithLevel("error").
WithMessage(sarif.NewTextMessage(event.Output))
run.AddResult(result)
}
}
if err := scanner.Err(); err != nil {
// Serialize to requested version
data, err := sarif.Serialize(report, opts.SARIFVersion, opts.Pretty)
if err != nil {
return err
}
report.AddRun(run)
if err := report.WriteFile(outputFile); err != nil {
// Write output
if err := os.WriteFile(outputFile, data, 0o644); err != nil {
return err
}
fmt.Printf("SARIF report generated: %s\n", outputFile)
return nil
}
func buildReport(events []testjson.TestEvent) *sarif.Report {
report := &sarif.Report{
ToolName: "go-test-sarif",
ToolInfoURI: "https://golang.org/cmd/go/#hdr-Test_packages",
Rules: []sarif.Rule{{
ID: "go-test-failure",
Description: "go test failure",
}},
}
for _, e := range events {
if e.Action == "fail" && (e.Test != "" || e.Package != "") {
result := sarif.Result{
RuleID: "go-test-failure",
Level: "error",
Message: e.Output,
}
result.Location = &sarif.LogicalLocation{
Module: e.Package,
Function: e.Test,
}
report.Results = append(report.Results, result)
}
}
return report
}

View File

@@ -4,85 +4,140 @@ import (
"os"
"path/filepath"
"testing"
"github.com/ivuorinen/go-test-sarif-action/internal/sarif"
"github.com/ivuorinen/go-test-sarif-action/internal/testutil"
)
// TestConvertToSARIF_Success tests the successful conversion of a valid Go test JSON output to SARIF format.
func TestConvertToSARIF_Success(t *testing.T) {
// testConvertHelper sets up input/output files and runs conversion
func testConvertHelper(t *testing.T, inputJSON string, opts ConvertOptions) ([]byte, error) {
t.Helper()
dir := t.TempDir()
inputPath := filepath.Join(dir, "input.json")
inputContent := `{"Action":"fail","Package":"github.com/ivuorinen/go-test-sarif/internal","Test":"TestExample","Output":"Test failed"}` + "\n"
if err := os.WriteFile(inputPath, []byte(inputContent), 0o600); err != nil {
inputPath := filepath.Join(dir, testutil.InputJSON)
if err := os.WriteFile(inputPath, []byte(inputJSON), 0o600); err != nil {
t.Fatalf("Failed to write input file: %v", err)
}
outputPath := filepath.Join(dir, "output.sarif")
if err := ConvertToSARIF(inputPath, outputPath); err != nil {
t.Errorf("ConvertToSARIF returned an error: %v", err)
}
outputContent, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("Failed to read SARIF output file: %v", err)
}
if len(outputContent) == 0 {
t.Errorf("SARIF output is empty")
}
}
// TestConvertToSARIF_InvalidInput tests the function's behavior when provided with invalid JSON input.
func TestConvertToSARIF_InvalidInput(t *testing.T) {
dir := t.TempDir()
inputPath := filepath.Join(dir, "invalid.json")
inputContent := `{"Action":"fail","Package":"github.com/ivuorinen/go-test-sarif/internal","Test":"TestExample","Output":` +
`Test failed}` + "\n" // Missing quotes around 'Test failed'
if err := os.WriteFile(inputPath, []byte(inputContent), 0o600); err != nil {
t.Fatalf("Failed to write input file: %v", err)
}
outputPath := filepath.Join(dir, "output.sarif")
if err := ConvertToSARIF(inputPath, outputPath); err == nil {
t.Errorf("Expected an error for invalid JSON input, but got none")
}
}
// TestConvertToSARIF_FileNotFound tests the function's behavior when the input file does not exist.
func TestConvertToSARIF_FileNotFound(t *testing.T) {
inputFile := "non_existent_file.json"
dir := t.TempDir()
outputPath := filepath.Join(dir, "output.sarif")
if err := ConvertToSARIF(inputFile, outputPath); err == nil {
t.Errorf("Expected an error for non-existent input file, but got none")
}
}
// TestConvertToSARIF_PackageFailure ensures package-level failures are included in the SARIF output.
func TestConvertToSARIF_PackageFailure(t *testing.T) {
dir := t.TempDir()
inputPath := filepath.Join(dir, "input.json")
inputContent := `{"Action":"fail","Package":"github.com/ivuorinen/go-test-sarif-action","Output":"FAIL"}` + "\n"
if err := os.WriteFile(inputPath, []byte(inputContent), 0o600); err != nil {
t.Fatalf("Failed to write input file: %v", err)
}
outputPath := filepath.Join(dir, "output.sarif")
if err := ConvertToSARIF(inputPath, outputPath); err != nil {
t.Errorf("ConvertToSARIF returned an error: %v", err)
outputPath := filepath.Join(dir, testutil.OutputSARIF)
if err := ConvertToSARIF(inputPath, outputPath, opts); err != nil {
return nil, err
}
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("Failed to read SARIF output file: %v", err)
t.Fatalf("Failed to read output file: %v", err)
}
return data, nil
}
func TestConvertToSARIF_Success(t *testing.T) {
inputJSON := `{"Action":"fail","Package":"github.com/ivuorinen/go-test-sarif/internal","Test":"TestExample","Output":"Test failed"}` + "\n"
data, err := testConvertHelper(t, inputJSON, DefaultConvertOptions())
if err != nil {
t.Errorf("ConvertToSARIF returned an error: %v", err)
}
if len(data) == 0 {
t.Errorf("SARIF output is empty")
}
}
func TestConvertToSARIF_InvalidInput(t *testing.T) {
dir := t.TempDir()
inputPath := filepath.Join(dir, "invalid.json")
inputContent := `{"Action":"fail","Package":"example.com","Output":` +
`Test failed}` + "\n"
if err := os.WriteFile(inputPath, []byte(inputContent), 0o600); err != nil {
t.Fatalf("Failed to write input file: %v", err)
}
outputPath := filepath.Join(dir, testutil.OutputSARIF)
opts := DefaultConvertOptions()
if err := ConvertToSARIF(inputPath, outputPath, opts); err == nil {
t.Errorf("Expected an error for invalid JSON input, but got none")
}
}
func TestConvertToSARIF_FileNotFound(t *testing.T) {
inputFile := "non_existent_file.json"
dir := t.TempDir()
outputPath := filepath.Join(dir, testutil.OutputSARIF)
opts := DefaultConvertOptions()
if err := ConvertToSARIF(inputFile, outputPath, opts); err == nil {
t.Errorf("Expected an error for non-existent input file, but got none")
}
}
func TestConvertToSARIF_PackageFailure(t *testing.T) {
inputJSON := `{"Action":"fail","Package":"github.com/ivuorinen/go-test-sarif-action","Output":"FAIL"}` + "\n"
data, err := testConvertHelper(t, inputJSON, DefaultConvertOptions())
if err != nil {
t.Errorf("ConvertToSARIF returned an error: %v", err)
}
if len(data) == 0 {
t.Errorf("SARIF output is empty")
}
}
func TestConvertToSARIF_Options(t *testing.T) {
dir := t.TempDir()
inputPath := filepath.Join(dir, testutil.InputJSON)
inputContent := `{"Action":"fail","Package":"example.com/foo","Test":"TestBar","Output":"failed"}` + "\n"
if err := os.WriteFile(inputPath, []byte(inputContent), 0o600); err != nil {
t.Fatalf("Failed to write input file: %v", err)
}
tests := []struct {
name string
opts ConvertOptions
wantErr bool
}{
{
name: "default options",
opts: DefaultConvertOptions(),
wantErr: false,
},
{
name: "v2.1.0 pretty",
opts: ConvertOptions{
SARIFVersion: sarif.Version210,
Pretty: true,
},
wantErr: false,
},
{
name: "v2.2",
opts: ConvertOptions{
SARIFVersion: sarif.Version22,
Pretty: false,
},
wantErr: false,
},
{
name: "invalid version",
opts: ConvertOptions{
SARIFVersion: "9.9.9",
Pretty: false,
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
outputPath := filepath.Join(dir, tt.name+".sarif")
err := ConvertToSARIF(inputPath, outputPath, tt.opts)
if (err != nil) != tt.wantErr {
t.Errorf("ConvertToSARIF() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

42
internal/sarif/model.go Normal file
View File

@@ -0,0 +1,42 @@
// Package sarif provides SARIF report generation.
package sarif
// Report is the internal version-agnostic representation of a SARIF report.
type Report struct {
// ToolName is the name of the tool that produced the results.
ToolName string
// ToolInfoURI is a URL for more information about the tool.
ToolInfoURI string
// Rules contains the rule definitions referenced by results.
Rules []Rule
// Results contains the actual findings/test failures.
Results []Result
}
// Rule defines a rule that can be violated.
type Rule struct {
// ID is the unique identifier for this rule.
ID string
// Description explains what this rule checks.
Description string
}
// Result represents a single finding.
type Result struct {
// RuleID references the rule that produced this result.
RuleID string
// Level indicates the severity (error, warning, note).
Level string
// Message describes the specific issue found.
Message string
// Location identifies where the issue was found.
Location *LogicalLocation
}
// LogicalLocation identifies where an issue occurred without file coordinates.
type LogicalLocation struct {
// Module is the Go module or package path.
Module string
// Function is the name of the function or test.
Function string
}

View File

@@ -0,0 +1,43 @@
// internal/sarif/model_test.go
package sarif
import "testing"
const (
testToolName = "test-tool"
testModuleName = "example.com/foo"
)
func TestReport_Structure(t *testing.T) {
report := &Report{
ToolName: testToolName,
ToolInfoURI: "https://example.com",
Rules: []Rule{
{ID: "rule-1", Description: "Test rule"},
},
Results: []Result{
{
RuleID: "rule-1",
Level: "error",
Message: "Test failed",
Location: &LogicalLocation{
Module: testModuleName,
Function: "TestBar",
},
},
},
}
if report.ToolName != testToolName {
t.Errorf("ToolName = %q, want %q", report.ToolName, testToolName)
}
if len(report.Rules) != 1 {
t.Errorf("len(Rules) = %d, want %d", len(report.Rules), 1)
}
if len(report.Results) != 1 {
t.Errorf("len(Results) = %d, want %d", len(report.Results), 1)
}
if report.Results[0].Location.Module != testModuleName {
t.Errorf("Location.Module = %q, want %q", report.Results[0].Location.Module, testModuleName)
}
}

107
internal/sarif/serialize.go Normal file
View File

@@ -0,0 +1,107 @@
package sarif
import "encoding/json"
// Internal SARIF document structure (version-agnostic)
type sarifDoc struct {
Schema string `json:"$schema"`
Version string `json:"version"`
Runs []run `json:"runs"`
}
type run struct {
Tool tool `json:"tool"`
Results []result `json:"results"`
}
type tool struct {
Driver driver `json:"driver"`
}
type driver struct {
Name string `json:"name"`
InformationURI string `json:"informationUri,omitempty"`
Rules []rule `json:"rules,omitempty"`
}
type rule struct {
ID string `json:"id"`
ShortDescription message `json:"shortDescription,omitempty"`
}
type result struct {
RuleID string `json:"ruleId"`
Level string `json:"level"`
Message message `json:"message"`
LogicalLocations []logicalLocation `json:"logicalLocations,omitempty"`
}
type message struct {
Text string `json:"text"`
}
type logicalLocation struct {
FullyQualifiedName string `json:"fullyQualifiedName,omitempty"`
Kind string `json:"kind,omitempty"`
}
// serializeWithVersion creates SARIF JSON with specified schema and version
func serializeWithVersion(r *Report, schema, version string) ([]byte, error) {
doc := sarifDoc{
Schema: schema,
Version: version,
Runs: []run{buildRun(r)},
}
return json.Marshal(doc)
}
func buildRun(r *Report) run {
rn := run{
Tool: tool{
Driver: driver{
Name: r.ToolName,
InformationURI: r.ToolInfoURI,
},
},
Results: make([]result, 0, len(r.Results)),
}
for _, rl := range r.Rules {
rn.Tool.Driver.Rules = append(rn.Tool.Driver.Rules, rule{
ID: rl.ID,
ShortDescription: message{Text: rl.Description},
})
}
for _, res := range r.Results {
r := result{
RuleID: res.RuleID,
Level: res.Level,
Message: message{Text: res.Message},
}
if res.Location != nil {
var fqn, kind string
switch {
case res.Location.Module != "" && res.Location.Function != "":
fqn = res.Location.Module + "." + res.Location.Function
kind = "function"
case res.Location.Function != "":
fqn = res.Location.Function
kind = "function"
case res.Location.Module != "":
fqn = res.Location.Module
kind = "module"
}
if fqn != "" {
r.LogicalLocations = []logicalLocation{
{FullyQualifiedName: fqn, Kind: kind},
}
}
}
rn.Results = append(rn.Results, r)
}
return rn
}

12
internal/sarif/v21.go Normal file
View File

@@ -0,0 +1,12 @@
// Package sarif provides SARIF report generation.
package sarif
func init() {
Register(Version210, serializeV21)
}
func serializeV21(r *Report) ([]byte, error) {
return serializeWithVersion(r,
"https://json.schemastore.org/sarif-2.1.0.json",
"2.1.0")
}

127
internal/sarif/v21_test.go Normal file
View File

@@ -0,0 +1,127 @@
// internal/sarif/v21_test.go
package sarif
import (
"encoding/json"
"testing"
)
const (
testRuleID = "test-failure"
testLevelError = "error"
)
func TestSerializeV21_Schema(t *testing.T) {
report := &Report{
ToolName: testToolName,
ToolInfoURI: "https://example.com",
}
data, err := Serialize(report, Version210, false)
if err != nil {
t.Fatalf("Serialize returned error: %v", err)
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
t.Fatalf("invalid JSON output: %v", err)
}
if result["$schema"] != "https://json.schemastore.org/sarif-2.1.0.json" {
t.Errorf("$schema = %v, want %v", result["$schema"], "https://json.schemastore.org/sarif-2.1.0.json")
}
if result["version"] != "2.1.0" {
t.Errorf("version = %v, want %v", result["version"], "2.1.0")
}
}
func TestSerializeV21_WithResults(t *testing.T) {
report := &Report{
ToolName: "go-test-sarif",
ToolInfoURI: "https://golang.org/cmd/go/",
Rules: []Rule{
{ID: testRuleID, Description: "Test failure"},
},
Results: []Result{
{
RuleID: testRuleID,
Level: testLevelError,
Message: "TestFoo failed",
},
},
}
data, err := Serialize(report, Version210, false)
if err != nil {
t.Fatalf("Serialize returned error: %v", err)
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
t.Fatalf("invalid JSON output: %v", err)
}
runs, ok := result["runs"].([]interface{})
if !ok || len(runs) != 1 {
t.Fatalf("expected 1 run, got %v", result["runs"])
}
run := runs[0].(map[string]interface{})
results, ok := run["results"].([]interface{})
if !ok || len(results) != 1 {
t.Fatalf("expected 1 result, got %v", run["results"])
}
res := results[0].(map[string]interface{})
if res["ruleId"] != testRuleID {
t.Errorf("ruleId = %v, want %v", res["ruleId"], testRuleID)
}
if res["level"] != testLevelError {
t.Errorf("level = %v, want %v", res["level"], testLevelError)
}
}
func TestSerializeV21_LogicalLocation(t *testing.T) {
report := &Report{
ToolName: "go-test-sarif",
Rules: []Rule{
{ID: testRuleID, Description: "Test failure"},
},
Results: []Result{
{
RuleID: testRuleID,
Level: testLevelError,
Message: "TestBar failed",
Location: &LogicalLocation{
Module: testModuleName,
Function: "TestBar",
},
},
},
}
data, err := Serialize(report, Version210, false)
if err != nil {
t.Fatalf("Serialize returned error: %v", err)
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
t.Fatalf("invalid JSON output: %v", err)
}
runs := result["runs"].([]interface{})
run := runs[0].(map[string]interface{})
results := run["results"].([]interface{})
res := results[0].(map[string]interface{})
locs, ok := res["logicalLocations"].([]interface{})
if !ok || len(locs) != 1 {
t.Fatalf("expected 1 logicalLocation, got %v", res["logicalLocations"])
}
loc := locs[0].(map[string]interface{})
if loc["fullyQualifiedName"] != "example.com/foo.TestBar" {
t.Errorf("fullyQualifiedName = %v, want %v", loc["fullyQualifiedName"], "example.com/foo.TestBar")
}
}

12
internal/sarif/v22.go Normal file
View File

@@ -0,0 +1,12 @@
// Package sarif provides SARIF report generation.
package sarif
func init() {
Register(Version22, serializeV22)
}
func serializeV22(r *Report) ([]byte, error) {
return serializeWithVersion(r,
"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/2.2-prerelease-2024-08-08/sarif-2.2/schema/sarif-2-2.schema.json",
"2.2")
}

View File

@@ -0,0 +1,74 @@
// internal/sarif/v22_test.go
package sarif
import (
"encoding/json"
"testing"
)
func TestSerializeV22_Schema(t *testing.T) {
report := &Report{
ToolName: testToolName,
ToolInfoURI: "https://example.com",
}
data, err := Serialize(report, Version22, false)
if err != nil {
t.Fatalf("Serialize returned error: %v", err)
}
var result map[string]any
if err := json.Unmarshal(data, &result); err != nil {
t.Fatalf("invalid JSON output: %v", err)
}
if result["$schema"] != "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/2.2-prerelease-2024-08-08/sarif-2.2/schema/sarif-2-2.schema.json" {
t.Errorf("$schema = %v", result["$schema"])
}
if result["version"] != "2.2" {
t.Errorf("version = %v, want %v", result["version"], "2.2")
}
}
func TestSerializeV22_WithResults(t *testing.T) {
report := &Report{
ToolName: "go-test-sarif",
Rules: []Rule{
{ID: testRuleID, Description: "Test failure"},
},
Results: []Result{
{
RuleID: testRuleID,
Level: testLevelError,
Message: "TestFoo failed",
Location: &LogicalLocation{
Module: testModuleName,
Function: "TestFoo",
},
},
},
}
data, err := Serialize(report, Version22, false)
if err != nil {
t.Fatalf("Serialize returned error: %v", err)
}
var result map[string]any
if err := json.Unmarshal(data, &result); err != nil {
t.Fatalf("invalid JSON output: %v", err)
}
runs := result["runs"].([]any)
run := runs[0].(map[string]any)
results := run["results"].([]any)
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
res := results[0].(map[string]any)
if res["ruleId"] != testRuleID {
t.Errorf("ruleId = %v, want %v", res["ruleId"], testRuleID)
}
}

66
internal/sarif/version.go Normal file
View File

@@ -0,0 +1,66 @@
// Package sarif provides SARIF report generation.
package sarif
import (
"bytes"
"encoding/json"
"fmt"
"sort"
)
// Version represents a SARIF specification version.
type Version string
const (
// Version210 is SARIF version 2.1.0.
Version210 Version = "2.1.0"
// Version22 is SARIF version 2.2.
Version22 Version = "2.2"
)
// DefaultVersion is the default SARIF version used when not specified.
const DefaultVersion = Version210
// Serializer converts an internal Report to version-specific JSON.
type Serializer func(*Report) ([]byte, error)
var serializers = map[Version]Serializer{}
// Register adds a serializer for a SARIF version.
// Called by version-specific files in their init() functions.
func Register(v Version, s Serializer) {
serializers[v] = s
}
// Serialize converts a Report to JSON for the specified SARIF version.
func Serialize(r *Report, v Version, pretty bool) ([]byte, error) {
s, ok := serializers[Version(v)]
if !ok {
return nil, fmt.Errorf("unsupported SARIF version: %s", v)
}
data, err := s(r)
if err != nil {
return nil, err
}
if pretty {
var buf bytes.Buffer
if err := json.Indent(&buf, data, "", " "); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
return data, nil
}
// SupportedVersions returns all registered SARIF versions, sorted.
func SupportedVersions() []string {
versions := make([]string, 0, len(serializers))
for v := range serializers {
versions = append(versions, string(v))
}
sort.Strings(versions)
return versions
}

View File

@@ -0,0 +1,83 @@
// internal/sarif/version_test.go
package sarif
import (
"bytes"
"strings"
"testing"
)
func TestSupportedVersions(t *testing.T) {
versions := SupportedVersions()
if len(versions) < 2 {
t.Errorf("expected at least 2 versions, got %d", len(versions))
}
// Should contain 2.1.0 and 2.2
found210 := false
found22 := false
for _, v := range versions {
if v == "2.1.0" {
found210 = true
}
if v == "2.2" {
found22 = true
}
}
if !found210 {
t.Error("SupportedVersions should contain 2.1.0")
}
if !found22 {
t.Error("SupportedVersions should contain 2.2")
}
}
func TestSerialize_UnknownVersion(t *testing.T) {
report := &Report{ToolName: "test"}
_, err := Serialize(report, "9.9.9", false)
if err == nil {
t.Fatal("expected error for unknown version, got nil")
}
if !strings.Contains(err.Error(), "unsupported") {
t.Errorf("error = %q, want to contain %q", err.Error(), "unsupported")
}
}
func TestDefaultVersion(t *testing.T) {
if DefaultVersion != Version210 {
t.Errorf("DefaultVersion = %q, want %q", DefaultVersion, Version210)
}
}
func TestSerialize_PrettyOutput(t *testing.T) {
report := &Report{
ToolName: "test-tool",
}
compact, err := Serialize(report, Version210, false)
if err != nil {
t.Fatalf("Serialize compact returned error: %v", err)
}
pretty, err := Serialize(report, Version210, true)
if err != nil {
t.Fatalf("Serialize pretty returned error: %v", err)
}
// Pretty should be longer due to whitespace
if len(pretty) <= len(compact) {
t.Errorf("pretty output (%d bytes) should be longer than compact (%d bytes)", len(pretty), len(compact))
}
// Pretty should contain newlines and indentation
if !bytes.Contains(pretty, []byte("\n")) {
t.Error("pretty output should contain newlines")
}
if !bytes.Contains(pretty, []byte(" ")) {
t.Error("pretty output should contain indentation")
}
}

View File

@@ -0,0 +1,60 @@
// Package testjson provides parsing utilities for go test -json output.
package testjson
import (
"bufio"
"encoding/json"
"fmt"
"os"
"time"
)
// TestEvent captures all fields from go test -json output.
type TestEvent struct {
// Time is when the event occurred.
Time time.Time `json:"Time"`
// Action is the event type (run, pass, fail, output, etc.).
Action string `json:"Action"`
// Package is the Go package being tested.
Package string `json:"Package"`
// Test is the name of the specific test, if applicable.
Test string `json:"Test,omitempty"`
// Elapsed is the duration in seconds for pass/fail events.
Elapsed float64 `json:"Elapsed,omitempty"`
// Output contains any text output from the test.
Output string `json:"Output,omitempty"`
// FailedBuild indicates if this was a build failure.
FailedBuild bool `json:"FailedBuild,omitempty"`
}
// ParseFile reads and parses a go test -json output file.
// Returns an error with line number if any line contains invalid JSON.
func ParseFile(path string) ([]TestEvent, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
var events []TestEvent
scanner := bufio.NewScanner(f)
// Increase buffer size for large JSON lines (e.g., verbose test output)
// Default is 64KB; allow up to 4MB per line
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
lineNum := 0
for scanner.Scan() {
lineNum++
var event TestEvent
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
return nil, fmt.Errorf("line %d: invalid JSON: %w", lineNum, err)
}
events = append(events, event)
}
if err := scanner.Err(); err != nil {
return nil, err
}
return events, nil
}

View File

@@ -0,0 +1,130 @@
// Package testjson provides parsing utilities for go test -json output.
package testjson
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
const (
testInputFile = "input.json"
testPackageName = "example.com/foo"
testTestName = "TestBar"
)
func TestParseFile_ValidInput(t *testing.T) {
dir := t.TempDir()
inputPath := filepath.Join(dir, testInputFile)
content := `{"Time":"2024-01-15T10:30:00Z","Action":"run","Package":"example.com/foo","Test":"TestBar"}
{"Time":"2024-01-15T10:30:01Z","Action":"output","Package":"example.com/foo","Test":"TestBar","Output":"=== RUN TestBar\n"}
{"Time":"2024-01-15T10:30:02Z","Action":"pass","Package":"example.com/foo","Test":"TestBar","Elapsed":0.5}
`
if err := os.WriteFile(inputPath, []byte(content), 0o600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
events, err := ParseFile(inputPath)
if err != nil {
t.Fatalf("ParseFile returned error: %v", err)
}
if len(events) != 3 {
t.Fatalf("expected 3 events, got %d", len(events))
}
// Check first event
if events[0].Action != "run" {
t.Errorf("event[0].Action = %q, want %q", events[0].Action, "run")
}
if events[0].Package != testPackageName {
t.Errorf("event[0].Package = %q, want %q", events[0].Package, testPackageName)
}
if events[0].Test != testTestName {
t.Errorf("event[0].Test = %q, want %q", events[0].Test, testTestName)
}
// Check elapsed on pass event
if events[2].Elapsed != 0.5 {
t.Errorf("event[2].Elapsed = %v, want %v", events[2].Elapsed, 0.5)
}
}
func TestParseFile_AllFields(t *testing.T) {
dir := t.TempDir()
inputPath := filepath.Join(dir, testInputFile)
// Event with all fields populated
content := `{"Time":"2024-01-15T10:30:00Z","Action":"fail","Package":"example.com/foo","Test":"TestBar","Elapsed":1.234,"Output":"FAIL\n","FailedBuild":true}
`
if err := os.WriteFile(inputPath, []byte(content), 0o600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
events, err := ParseFile(inputPath)
if err != nil {
t.Fatalf("ParseFile returned error: %v", err)
}
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
e := events[0]
expectedTime, _ := time.Parse(time.RFC3339, "2024-01-15T10:30:00Z")
if !e.Time.Equal(expectedTime) {
t.Errorf("Time = %v, want %v", e.Time, expectedTime)
}
if e.Action != "fail" {
t.Errorf("Action = %q, want %q", e.Action, "fail")
}
if e.Package != testPackageName {
t.Errorf("Package = %q, want %q", e.Package, testPackageName)
}
if e.Test != testTestName {
t.Errorf("Test = %q, want %q", e.Test, testTestName)
}
if e.Elapsed != 1.234 {
t.Errorf("Elapsed = %v, want %v", e.Elapsed, 1.234)
}
if e.Output != "FAIL\n" {
t.Errorf("Output = %q, want %q", e.Output, "FAIL\n")
}
if !e.FailedBuild {
t.Errorf("FailedBuild = %v, want %v", e.FailedBuild, true)
}
}
func TestParseFile_MalformedJSON(t *testing.T) {
dir := t.TempDir()
inputPath := filepath.Join(dir, testInputFile)
content := `{"Action":"pass","Package":"example.com/foo"}
{"Action":"fail","Package":broken json here}
{"Action":"skip","Package":"example.com/bar"}
`
if err := os.WriteFile(inputPath, []byte(content), 0o600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
_, err := ParseFile(inputPath)
if err == nil {
t.Fatal("expected error for malformed JSON, got nil")
}
// Error should mention line 2
if got := err.Error(); !strings.Contains(got, "line 2") {
t.Errorf("error = %q, want to contain %q", got, "line 2")
}
}
func TestParseFile_FileNotFound(t *testing.T) {
_, err := ParseFile("/nonexistent/path/to/file.json")
if err == nil {
t.Fatal("expected error for nonexistent file, got nil")
}
}

View File

@@ -0,0 +1,16 @@
// Package testutil provides shared test utilities and constants.
package testutil
const (
// VersionOutput is the expected version output string for tests.
VersionOutput = "go-test-sarif dev"
// AppName is the application name used in test arguments.
AppName = "go-test-sarif"
// InputJSON is the placeholder input file name for tests.
InputJSON = "input.json"
// OutputSARIF is the placeholder output file name for tests.
OutputSARIF = "output.sarif"
)