diff --git a/.ecrc b/.ecrc new file mode 100644 index 0000000..64cb92e --- /dev/null +++ b/.ecrc @@ -0,0 +1,5 @@ +{ + "Exclude": [ + "docs/plans/.*\\.md$" + ] +} diff --git a/.editorconfig b/.editorconfig index b9c1dda..32683e1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -12,4 +12,3 @@ trim_trailing_whitespace = true [*.go] indent_style = tab - diff --git a/.github/renovate.json b/.github/renovate.json index 66f4a27..f02f654 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,4 +1,6 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["github>ivuorinen/renovate-config"] + "extends": [ + "github>ivuorinen/renovate-config" + ] } diff --git a/.golangci.yml b/.golangci.yml index c129e12..bf90696 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,3 +1,4 @@ +--- version: "2" run: timeout: 5m diff --git a/.goreleaser.yml b/.goreleaser.yml index 4e4cab4..9a34085 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,3 +1,4 @@ +--- version: 2 before: @@ -27,12 +28,8 @@ builds: archives: - name_template: >- - {{ .ProjectName }}_ - {{- title .Os }}_ - {{- if eq .Arch "amd64" }}x86_64 - {{- else if eq .Arch "386" }}i386 - {{- else }}{{ .Arch }}{{ end }} - {{- if .Arm }}v{{ .Arm }}{{ end }} + {{ .ProjectName }}_ {{- title .Os }}_ {{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "386" }}i386 {{- else }}{{ + .Arch }}{{ end }} {{- if .Arm }}v{{ .Arm }}{{ end }} builds_info: group: root owner: root diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..22f561e --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,10 @@ +{ + "MD013": { + "line_length": 80, + "code_blocks": false, + "tables": false + }, + "MD024": { + "siblings_only": true + } +} diff --git a/.mega-linter.yml b/.mega-linter.yml index b5baa4e..e25ca30 100644 --- a/.mega-linter.yml +++ b/.mega-linter.yml @@ -4,18 +4,40 @@ # https://megalinter.io/configuration/ and in linters documentation APPLY_FIXES: all -SHOW_ELAPSED_TIME: false # Show elapsed time at the end of MegaLinter run +SHOW_ELAPSED_TIME: false PARALLEL: true VALIDATE_ALL_CODEBASE: true -FILEIO_REPORTER: false # Generate file.io report -GITHUB_STATUS_REPORTER: true # Generate GitHub status report -IGNORE_GENERATED_FILES: true # Ignore generated files -JAVASCRIPT_DEFAULT_STYLE: prettier # Default style for JavaScript -PRINT_ALPACA: false # Print Alpaca logo in console -SARIF_REPORTER: true # Generate SARIF report -SHOW_SKIPPED_LINTERS: false # Show skipped linters in MegaLinter log +FILEIO_REPORTER: false +GITHUB_STATUS_REPORTER: true +IGNORE_GENERATED_FILES: true +PRINT_ALPACA: false +SARIF_REPORTER: true +SHOW_SKIPPED_LINTERS: false +GO_REVIVE_CLI_LINT_MODE: "project" DISABLE_LINTERS: - REPOSITORY_DEVSKIM - - GO_GOLANGCI_LINT # old go version, fails always + - REPOSITORY_TRIVY + - REPOSITORY_TRIVY_SBOM + - REPOSITORY_TRUFFLEHOG + - REPOSITORY_CHECKOV + - REPOSITORY_KICS + - REPOSITORY_GRYPE + - SPELL_CSPELL + - SPELL_MISSPELL + - COPYPASTE_JSCPD +ENABLE_LINTERS: + - GO_GOLANGCI_LINT + - GO_REVIVE + - YAML_YAMLLINT + - MARKDOWN_MARKDOWNLINT + - JSON_JSONLINT + - BASH_SHELLCHECK + - BASH_SHFMT + - DOCKERFILE_HADOLINT + - ACTION_ACTIONLINT + +MARKDOWN_MARKDOWNLINT_CONFIG_FILE: .markdownlint.json + +FILTER_REGEX_EXCLUDE: (testdata|\.git) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a23b02d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,79 @@ +--- +repos: + # Built-in pre-commit hooks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: detect-private-key + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + exclude: "^testdata/" + - id: check-case-conflict + - id: check-merge-conflict + - id: check-executables-have-shebangs + - id: check-shebang-scripts-are-executable + - id: check-symlinks + - id: check-toml + - id: check-xml + - id: check-yaml + args: [--allow-multiple-documents] + exclude: "^testdata/" + - id: end-of-file-fixer + exclude: "^testdata/" + - id: mixed-line-ending + args: [--fix=auto] + - id: pretty-format-json + args: [--autofix, --no-sort-keys] + + # YAML formatting with yamlfmt (replaces yamllint for formatting) + - repo: https://github.com/google/yamlfmt + rev: v0.21.0 + hooks: + - id: yamlfmt + exclude: "^testdata/" + + # Markdown linting with markdownlint-cli2 (excluding legacy files) + - repo: https://github.com/DavidAnson/markdownlint-cli2 + rev: v0.20.0 + hooks: + - id: markdownlint-cli2 + args: [--fix] + exclude: "^testdata/" + + # EditorConfig checking + - repo: https://github.com/editorconfig-checker/editorconfig-checker + rev: v3.6.0 + hooks: + - id: editorconfig-checker + alias: ec + + # Go formatting, imports, and linting + - repo: https://github.com/TekWizely/pre-commit-golang + rev: v1.0.0-rc.4 + hooks: + - id: go-imports-repo + args: [-w] + - id: go-mod-tidy + - id: golangci-lint-repo-mod + args: [--fix] + + # Shell formatting and linting + - repo: https://github.com/scop/pre-commit-shfmt + rev: v3.12.0-2 + hooks: + - id: shfmt + + # GitHub Actions linting + - repo: https://github.com/rhysd/actionlint + rev: v1.7.10 + hooks: + - id: actionlint + args: ["-shellcheck="] + + # Commit message linting + - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook + rev: v9.24.0 + hooks: + - id: commitlint + stages: [commit-msg] + additional_dependencies: ["@commitlint/config-conventional"] diff --git a/cmd/main.go b/cmd/main.go index 179484f..d449192 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -6,14 +6,21 @@ import ( "fmt" "io" "os" + "strings" "github.com/ivuorinen/go-test-sarif-action/internal" + "github.com/ivuorinen/go-test-sarif-action/internal/sarif" ) +// Build-time variables set via ldflags. var ( + // version is the application version, set at build time. version = "dev" - commit = "none" - date = "unknown" + // commit is the git commit hash, set at build time. + commit = "none" + // date is the build date, set at build time. + date = "unknown" + // builtBy is the builder identifier, set at build time. builtBy = "unknown" ) @@ -25,17 +32,32 @@ func printVersion(w io.Writer) { } func printUsage(w io.Writer) { - _, _ = fmt.Fprintln(w, "Usage: go-test-sarif ") + _, _ = fmt.Fprintln(w, "Usage: go-test-sarif [options] ") _, _ = fmt.Fprintln(w, " go-test-sarif --version") + _, _ = fmt.Fprintln(w, "") + _, _ = fmt.Fprintln(w, "Options:") + _, _ = fmt.Fprintf(w, " --sarif-version string SARIF version (%s) (default %q)\n", + strings.Join(sarif.SupportedVersions(), ", "), sarif.DefaultVersion) + _, _ = fmt.Fprintln(w, " --pretty Pretty-print JSON output") + _, _ = fmt.Fprintln(w, " -v, --version Display version information") } func run(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("go-test-sarif", flag.ContinueOnError) fs.SetOutput(stderr) - var versionFlag bool + var ( + versionFlag bool + sarifVersion string + prettyOutput bool + ) + fs.BoolVar(&versionFlag, "version", false, "Display version information") fs.BoolVar(&versionFlag, "v", false, "Display version information (short)") + fs.StringVar(&sarifVersion, "sarif-version", string(sarif.DefaultVersion), + fmt.Sprintf("SARIF version (%s)", strings.Join(sarif.SupportedVersions(), ", "))) + fs.BoolVar(&prettyOutput, "pretty", false, "Pretty-print JSON output") + if err := fs.Parse(args[1:]); err != nil { return 1 } @@ -53,7 +75,12 @@ func run(args []string, stdout, stderr io.Writer) int { inputFile := fs.Arg(0) outputFile := fs.Arg(1) - if err := internal.ConvertToSARIF(inputFile, outputFile); err != nil { + opts := internal.ConvertOptions{ + SARIFVersion: sarif.Version(sarifVersion), + Pretty: prettyOutput, + } + + if err := internal.ConvertToSARIF(inputFile, outputFile, opts); err != nil { _, _ = fmt.Fprintf(stderr, "Error: %v\n", err) return 1 } diff --git a/cmd/main_test.go b/cmd/main_test.go index df0e80a..71b8435 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -6,97 +6,132 @@ import ( "path/filepath" "strings" "testing" + + "github.com/ivuorinen/go-test-sarif-action/internal/testutil" ) +type runTestCase struct { + name string + args []string + setupFunc func() (string, string, func()) + wantExit int + wantStdout string + wantStderr string +} + +// runTestCaseHelper executes a single test case and validates results. +func runTestCaseHelper(t *testing.T, tc runTestCase) { + t.Helper() + + args := make([]string, len(tc.args)) + copy(args, tc.args) + + var cleanup func() + if tc.setupFunc != nil { + inputFile, outputFile, cleanupFunc := tc.setupFunc() + cleanup = cleanupFunc + args = replaceFilePlaceholders(args, inputFile, outputFile) + } + if cleanup != nil { + defer cleanup() + } + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + exitCode := run(args, stdout, stderr) + + if exitCode != tc.wantExit { + t.Errorf("exit code = %v, want %v", exitCode, tc.wantExit) + } + if tc.wantStdout != "" && !strings.Contains(stdout.String(), tc.wantStdout) { + t.Errorf("stdout = %q, want to contain %q", stdout.String(), tc.wantStdout) + } + if tc.wantStderr != "" && !strings.Contains(stderr.String(), tc.wantStderr) { + t.Errorf("stderr = %q, want to contain %q", stderr.String(), tc.wantStderr) + } +} + +// replaceFilePlaceholders replaces placeholder file names with actual paths. +func replaceFilePlaceholders(args []string, inputFile, outputFile string) []string { + for i, arg := range args { + switch arg { + case testutil.InputJSON: + args[i] = inputFile + case testutil.OutputSARIF: + args[i] = outputFile + } + } + return args +} + func TestRun(t *testing.T) { - tests := []struct { - name string - args []string - setupFunc func() (string, string, func()) - wantExit int - wantStdout string - wantStderr string - }{ + tests := []runTestCase{ { name: "version flag long", - args: []string{"go-test-sarif", "--version"}, + args: []string{testutil.AppName, "--version"}, wantExit: 0, - wantStdout: "go-test-sarif dev", + wantStdout: testutil.VersionOutput, }, { name: "version flag short", - args: []string{"go-test-sarif", "-v"}, + args: []string{testutil.AppName, "-v"}, wantExit: 0, - wantStdout: "go-test-sarif dev", + wantStdout: testutil.VersionOutput, }, { name: "missing arguments", - args: []string{"go-test-sarif"}, + args: []string{testutil.AppName}, wantExit: 1, - wantStderr: "Usage: go-test-sarif", + wantStderr: "Usage: " + testutil.AppName, }, { name: "only one argument", - args: []string{"go-test-sarif", "input.json"}, + args: []string{testutil.AppName, testutil.InputJSON}, wantExit: 1, - wantStderr: "Usage: go-test-sarif", + wantStderr: "Usage: " + testutil.AppName, }, { - name: "valid conversion", - args: []string{"go-test-sarif", "input.json", "output.sarif"}, + name: "valid conversion", + args: []string{testutil.AppName, testutil.InputJSON, testutil.OutputSARIF}, setupFunc: setupValidTestFiles, - wantExit: 0, + wantExit: 0, }, { name: "invalid input file", - args: []string{"go-test-sarif", "nonexistent.json", "output.sarif"}, + args: []string{testutil.AppName, "nonexistent.json", testutil.OutputSARIF}, wantExit: 1, wantStderr: "Error:", }, { name: "invalid flag", - args: []string{"go-test-sarif", "--invalid"}, + args: []string{testutil.AppName, "--invalid"}, wantExit: 1, wantStderr: "flag provided but not defined", }, + { + name: "with sarif-version flag", + args: []string{testutil.AppName, "--sarif-version", "2.2", testutil.InputJSON, testutil.OutputSARIF}, + setupFunc: setupValidTestFiles, + wantExit: 0, + }, + { + name: "with pretty flag", + args: []string{testutil.AppName, "--pretty", testutil.InputJSON, testutil.OutputSARIF}, + setupFunc: setupValidTestFiles, + wantExit: 0, + }, + { + name: "invalid sarif version", + args: []string{testutil.AppName, "--sarif-version", "9.9.9", testutil.InputJSON, testutil.OutputSARIF}, + setupFunc: setupValidTestFiles, + wantExit: 1, + wantStderr: "Error:", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var cleanup func() - if tt.setupFunc != nil { - inputFile, outputFile, cleanupFunc := tt.setupFunc() - cleanup = cleanupFunc - // Replace placeholders with actual file paths - for i, arg := range tt.args { - switch arg { - case "input.json": - tt.args[i] = inputFile - case "output.sarif": - tt.args[i] = outputFile - } - } - } - if cleanup != nil { - defer cleanup() - } - - stdout := &bytes.Buffer{} - stderr := &bytes.Buffer{} - - exitCode := run(tt.args, stdout, stderr) - - if exitCode != tt.wantExit { - t.Errorf("run() exit code = %v, want %v", exitCode, tt.wantExit) - } - - if tt.wantStdout != "" && !strings.Contains(stdout.String(), tt.wantStdout) { - t.Errorf("stdout = %q, want to contain %q", stdout.String(), tt.wantStdout) - } - - if tt.wantStderr != "" && !strings.Contains(stderr.String(), tt.wantStderr) { - t.Errorf("stderr = %q, want to contain %q", stderr.String(), tt.wantStderr) - } + runTestCaseHelper(t, tt) }) } } @@ -106,8 +141,8 @@ func TestPrintVersion(t *testing.T) { printVersion(buf) output := buf.String() - if !strings.Contains(output, "go-test-sarif dev") { - t.Errorf("printVersion() = %q, want to contain %q", output, "go-test-sarif dev") + if !strings.Contains(output, testutil.VersionOutput) { + t.Errorf("printVersion() = %q, want to contain %q", output, testutil.VersionOutput) } if !strings.Contains(output, "commit: none") { t.Errorf("printVersion() = %q, want to contain %q", output, "commit: none") @@ -119,9 +154,15 @@ func TestPrintUsage(t *testing.T) { printUsage(buf) output := buf.String() - if !strings.Contains(output, "Usage: go-test-sarif ") { + if !strings.Contains(output, "Usage: "+testutil.AppName) { t.Errorf("printUsage() = %q, want to contain usage information", output) } + if !strings.Contains(output, "--sarif-version") { + t.Errorf("printUsage() = %q, want to contain --sarif-version flag", output) + } + if !strings.Contains(output, "--pretty") { + t.Errorf("printUsage() = %q, want to contain --pretty flag", output) + } } func setupValidTestFiles() (string, string, func()) { diff --git a/docs/plans/2026-01-20-replace-go-sarif-design.md b/docs/plans/2026-01-20-replace-go-sarif-design.md new file mode 100644 index 0000000..2fd0b02 --- /dev/null +++ b/docs/plans/2026-01-20-replace-go-sarif-design.md @@ -0,0 +1,230 @@ +# Design: Replace go-sarif with Internal SARIF Implementation + +## Context + +The `gopkg.in/yaml.v3` dependency is archived and unmaintained. +It enters our dependency graph through: + +```text +go-sarif/v2 → testify → gopkg.in/yaml.v3 +``` + +This project uses a minimal subset of go-sarif. Replacing it with an +internal implementation eliminates all external dependencies and the +yaml.v3 vulnerability. + +## Goals + +- Remove go-sarif dependency entirely +- Support SARIF v2.1.0 and v2.2 with extensible version system +- Capture all `go test -json` fields for future use +- Add logical location info (package/test name) to results +- Zero external dependencies after migration + +## Package Structure + +```text +internal/ +├── sarif/ +│ ├── model.go # Internal SARIF data model +│ ├── version.go # Version enum and registry +│ ├── serialize.go # Common serialization logic +│ ├── v21.go # SARIF 2.1.0 serializer +│ └── v22.go # SARIF 2.2 serializer +├── testjson/ +│ └── parser.go # Go test JSON parser (all 7 fields) +└── converter.go # Orchestrates parsing → model → SARIF output +``` + +## Internal Data Model + +Version-agnostic model that captures all relevant data: + +```go +// internal/sarif/model.go + +type Report struct { + ToolName string + ToolInfoURI string + Rules []Rule + Results []Result +} + +type Rule struct { + ID string + Description string +} + +type Result struct { + RuleID string + Level string // "error", "warning", "note" + Message string + Location *LogicalLocation +} + +type LogicalLocation struct { + Module string // Package name + Function string // Test name +} +``` + +## Test Event Parser + +Captures all 7 fields from `go test -json`: + +```go +// internal/testjson/parser.go + +type TestEvent struct { + Time time.Time `json:"Time"` + Action string `json:"Action"` + Package string `json:"Package"` + Test string `json:"Test,omitempty"` + Elapsed float64 `json:"Elapsed,omitempty"` + Output string `json:"Output,omitempty"` + FailedBuild string `json:"FailedBuild,omitempty"` +} + +func ParseFile(path string) ([]TestEvent, error) +``` + +Fails fast on malformed JSON with line numbers in error messages. + +## Version Registry + +Extensible system for adding SARIF versions: + +```go +// internal/sarif/version.go + +type Version string + +const ( + Version210 Version = "2.1.0" + Version22 Version = "2.2" +) + +const DefaultVersion = Version210 + +type Serializer func(*Report) ([]byte, error) + +var serializers = map[Version]Serializer{} + +func Register(v Version, s Serializer) +func Serialize(r *Report, v Version, pretty bool) ([]byte, error) +func SupportedVersions() []string +``` + +Adding a new version requires: + +1. Create version file (e.g., `v23.go`) with serializer function +2. Add version constant +3. Register in `init()` + +## Version-Specific Serializers + +Each version has its own JSON schema structs: + +```go +// internal/sarif/v21.go + +type sarifV21 struct { + Schema string `json:"$schema"` + Version string `json:"version"` + Runs []runV21 `json:"runs"` +} + +func serializeV21(r *Report) ([]byte, error) +``` + +SARIF v2.2 follows the same pattern with its schema differences. + +## CLI Interface + +```shell +go-test-sarif +go-test-sarif --sarif-version 2.2 +go-test-sarif --pretty +go-test-sarif --version +``` + +Flags: + +- `--sarif-version`: SARIF output version (default: 2.1.0) +- `--pretty`: Pretty-print JSON output with indentation +- `--version`, `-v`: Display tool version + +Help text for `--sarif-version` dynamically lists registered versions. + +## Go API + +```go +// internal/converter.go + +type ConvertOptions struct { + SARIFVersion sarif.Version + Pretty bool +} + +func ConvertToSARIF(inputFile, outputFile string, opts ConvertOptions) error +``` + +## Output Format + +- Compact JSON by default (no indentation) +- `--pretty` flag enables 2-space indented output + +## Error Handling + +- Fail fast on malformed JSON input +- Error messages include line numbers +- Return error for unsupported SARIF version + +## Testing Strategy + +```text +internal/testjson/parser_test.go +- TestParseFile_ValidInput +- TestParseFile_AllFields +- TestParseFile_MalformedJSON +- TestParseFile_FileNotFound + +internal/sarif/version_test.go +- TestSupportedVersions +- TestSerialize_UnknownVersion +- TestSerialize_PrettyOutput + +internal/sarif/v21_test.go +- TestSerializeV21_Schema +- TestSerializeV21_WithResults +- TestSerializeV21_LogicalLocation + +internal/sarif/v22_test.go +- TestSerializeV22_Schema +- TestSerializeV22_WithResults + +internal/converter_test.go +- TestConvertToSARIF_Success (update existing) +- TestConvertToSARIF_Options +``` + +## Migration Steps + +1. Implement `internal/testjson/` package +2. Implement `internal/sarif/` package with v2.1.0 and v2.2 serializers +3. Update `internal/converter.go` to use new packages +4. Update `cmd/main.go` with new CLI flags +5. Update existing tests, add new tests +6. Remove go-sarif import +7. Run `go mod tidy` +8. Verify: `go mod graph | grep yaml` returns nothing +9. Run full test suite + +## Result + +After migration: + +- Zero external dependencies +- No yaml.v3 in dependency graph +- Extensible SARIF version support +- Richer output with logical location info diff --git a/docs/plans/2026-01-20-replace-go-sarif-implementation.md b/docs/plans/2026-01-20-replace-go-sarif-implementation.md new file mode 100644 index 0000000..b220576 --- /dev/null +++ b/docs/plans/2026-01-20-replace-go-sarif-implementation.md @@ -0,0 +1,1718 @@ +# Replace go-sarif Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans +> to implement this plan task-by-task. + +**Goal:** Replace the go-sarif dependency with a minimal internal SARIF +implementation, eliminating the yaml.v3 transitive dependency. + +**Architecture:** Three-layer design: (1) testjson parser captures all +go test -json fields, (2) internal SARIF model is version-agnostic, +(3) version-specific serializers output SARIF v2.1.0 or v2.2. +Registry pattern enables adding future versions. + +**Tech Stack:** Go standard library only +(encoding/json, bufio, os, time, sort, bytes, fmt) + +**Note:** We implement SARIF v2.1.0 and v2.2 (the currently supported specifications). + +--- + +## Task 1: Test JSON Parser - Types and Basic Test + +**Files:** + +- Create: `internal/testjson/parser.go` +- Create: `internal/testjson/parser_test.go` + +### Step 1: Write the failing test for TestEvent struct and ParseFile + +```go +// internal/testjson/parser_test.go +package testjson + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestParseFile_ValidInput(t *testing.T) { + dir := t.TempDir() + inputPath := filepath.Join(dir, "input.json") + + 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), 0o644); 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 != "example.com/foo" { + t.Errorf("event[0].Package = %q, want %q", events[0].Package, "example.com/foo") + } + if events[0].Test != "TestBar" { + t.Errorf("event[0].Test = %q, want %q", events[0].Test, "TestBar") + } + + // Check elapsed on pass event + if events[2].Elapsed != 0.5 { + t.Errorf("event[2].Elapsed = %v, want %v", events[2].Elapsed, 0.5) + } +} +``` + +### Step 2: Run test to verify it fails + +Run: `go test ./internal/testjson/... -v` +Expected: Build failure - package doesn't exist + +### Step 3: Write minimal implementation + +```go +// internal/testjson/parser.go +package testjson + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "time" +) + +// TestEvent captures all fields from go test -json output. +type TestEvent struct { + Time time.Time `json:"Time"` + Action string `json:"Action"` + Package string `json:"Package"` + Test string `json:"Test,omitempty"` + Elapsed float64 `json:"Elapsed,omitempty"` + Output string `json:"Output,omitempty"` + FailedBuild string `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 f.Close() + + var events []TestEvent + scanner := bufio.NewScanner(f) + 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 +} +``` + +### Step 4: Run test to verify it passes + +Run: `go test ./internal/testjson/... -v` +Expected: PASS + +### Step 5: Commit + +```bash +git add internal/testjson/ +git commit -m "feat(testjson): add parser for go test -json output" +``` + +--- + +## Task 2: Test JSON Parser - Additional Tests + +**Files:** + +- Modify: `internal/testjson/parser_test.go` + +### Step 1: Add tests for all fields, malformed JSON, and file not found + +```go +// Add to internal/testjson/parser_test.go + +func TestParseFile_AllFields(t *testing.T) { + dir := t.TempDir() + inputPath := filepath.Join(dir, "input.json") + + // 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":"example.com/broken"} +` + if err := os.WriteFile(inputPath, []byte(content), 0o644); 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 != "example.com/foo" { + t.Errorf("Package = %q, want %q", e.Package, "example.com/foo") + } + if e.Test != "TestBar" { + t.Errorf("Test = %q, want %q", e.Test, "TestBar") + } + 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 != "example.com/broken" { + t.Errorf("FailedBuild = %q, want %q", e.FailedBuild, "example.com/broken") + } +} + +func TestParseFile_MalformedJSON(t *testing.T) { + dir := t.TempDir() + inputPath := filepath.Join(dir, "input.json") + + 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), 0o644); 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(); !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") + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsAt(s, substr)) +} + +func containsAt(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} +``` + +### Step 2: Run tests to verify they pass + +Run: `go test ./internal/testjson/... -v` +Expected: All PASS + +### Step 3: Commit + +```bash +git add internal/testjson/parser_test.go +git commit -m "test(testjson): add tests for all fields, malformed JSON, file not found" +``` + +--- + +## Task 3: SARIF Model + +**Files:** + +- Create: `internal/sarif/model.go` +- Create: `internal/sarif/model_test.go` + +### Step 1: Write test for model types + +```go +// internal/sarif/model_test.go +package sarif + +import "testing" + +func TestReport_Structure(t *testing.T) { + report := &Report{ + ToolName: "test-tool", + 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: "example.com/foo", + Function: "TestBar", + }, + }, + }, + } + + if report.ToolName != "test-tool" { + t.Errorf("ToolName = %q, want %q", report.ToolName, "test-tool") + } + 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 != "example.com/foo" { + t.Errorf("Location.Module = %q, want %q", report.Results[0].Location.Module, "example.com/foo") + } +} +``` + +### Step 2: Run test to verify it fails + +Run: `go test ./internal/sarif/... -v` +Expected: Build failure - package doesn't exist + +### Step 3: Write implementation + +```go +// internal/sarif/model.go +package sarif + +// Report is the internal version-agnostic representation of a SARIF report. +type Report struct { + ToolName string + ToolInfoURI string + Rules []Rule + Results []Result +} + +// Rule defines a rule that can be violated. +type Rule struct { + ID string + Description string +} + +// Result represents a single finding. +type Result struct { + RuleID string + Level string // "error", "warning", "note" + Message string + Location *LogicalLocation +} + +// LogicalLocation identifies where an issue occurred without file coordinates. +type LogicalLocation struct { + Module string // Package name (e.g., "github.com/foo/bar") + Function string // Test or function name (e.g., "TestExample") +} +``` + +### Step 4: Run test to verify it passes + +Run: `go test ./internal/sarif/... -v` +Expected: PASS + +### Step 5: Commit + +```bash +git add internal/sarif/ +git commit -m "feat(sarif): add internal SARIF data model" +``` + +--- + +## Task 4: SARIF Version Registry + +**Files:** + +- Create: `internal/sarif/version.go` +- Create: `internal/sarif/version_test.go` + +### Step 1: Write failing tests for version registry + +```go +// internal/sarif/version_test.go +package sarif + +import ( + "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) + } +} +``` + +### Step 2: Run tests to verify they fail + +Run: `go test ./internal/sarif/... -v` +Expected: Build failure - functions not defined + +### Step 3: Write implementation + +```go +// internal/sarif/version.go +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 +} +``` + +### Step 4: Run tests to verify they fail (no serializers registered yet) + +Run: `go test ./internal/sarif/... -v` +Expected: FAIL - no versions registered + +### Step 5: Commit partial progress + +```bash +git add internal/sarif/version.go internal/sarif/version_test.go +git commit -m "feat(sarif): add version registry (serializers pending)" +``` + +--- + +## Task 5: SARIF v2.1.0 Serializer + +**Files:** + +- Create: `internal/sarif/v21.go` +- Create: `internal/sarif/v21_test.go` + +### Step 1: Write failing tests for v2.1.0 serializer + +```go +// internal/sarif/v21_test.go +package sarif + +import ( + "encoding/json" + "testing" +) + +func TestSerializeV21_Schema(t *testing.T) { + report := &Report{ + ToolName: "test-tool", + 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: "test-failure", Description: "Test failure"}, + }, + Results: []Result{ + { + RuleID: "test-failure", + Level: "error", + 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"] != "test-failure" { + t.Errorf("ruleId = %v, want %v", res["ruleId"], "test-failure") + } + if res["level"] != "error" { + t.Errorf("level = %v, want %v", res["level"], "error") + } +} + +func TestSerializeV21_LogicalLocation(t *testing.T) { + report := &Report{ + ToolName: "go-test-sarif", + Rules: []Rule{ + {ID: "test-failure", Description: "Test failure"}, + }, + Results: []Result{ + { + RuleID: "test-failure", + Level: "error", + Message: "TestBar failed", + Location: &LogicalLocation{ + Module: "example.com/foo", + 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") + } +} +``` + +### Step 2: Run tests to verify they fail + +Run: `go test ./internal/sarif/... -v -run V21` +Expected: FAIL - v2.1.0 serializer not registered + +### Step 3: Write implementation + +```go +// internal/sarif/v21.go +package sarif + +import "encoding/json" + +func init() { + Register(Version210, serializeV21) +} + +// SARIF v2.1.0 JSON structures + +type sarifV21 struct { + Schema string `json:"$schema"` + Version string `json:"version"` + Runs []runV21 `json:"runs"` +} + +type runV21 struct { + Tool toolV21 `json:"tool"` + Results []resultV21 `json:"results"` +} + +type toolV21 struct { + Driver driverV21 `json:"driver"` +} + +type driverV21 struct { + Name string `json:"name"` + InformationURI string `json:"informationUri,omitempty"` + Rules []ruleV21 `json:"rules,omitempty"` +} + +type ruleV21 struct { + ID string `json:"id"` + ShortDescription messageV21 `json:"shortDescription,omitempty"` +} + +type resultV21 struct { + RuleID string `json:"ruleId"` + Level string `json:"level"` + Message messageV21 `json:"message"` + LogicalLocations []logicalLocationV21 `json:"logicalLocations,omitempty"` +} + +type messageV21 struct { + Text string `json:"text"` +} + +type logicalLocationV21 struct { + FullyQualifiedName string `json:"fullyQualifiedName,omitempty"` + Kind string `json:"kind,omitempty"` +} + +func serializeV21(r *Report) ([]byte, error) { + doc := sarifV21{ + Schema: "https://json.schemastore.org/sarif-2.1.0.json", + Version: "2.1.0", + Runs: []runV21{buildRunV21(r)}, + } + return json.Marshal(doc) +} + +func buildRunV21(r *Report) runV21 { + run := runV21{ + Tool: toolV21{ + Driver: driverV21{ + Name: r.ToolName, + InformationURI: r.ToolInfoURI, + }, + }, + Results: make([]resultV21, 0, len(r.Results)), + } + + // Add rules + for _, rule := range r.Rules { + run.Tool.Driver.Rules = append(run.Tool.Driver.Rules, ruleV21{ + ID: rule.ID, + ShortDescription: messageV21{Text: rule.Description}, + }) + } + + // Add results + for _, result := range r.Results { + res := resultV21{ + RuleID: result.RuleID, + Level: result.Level, + Message: messageV21{Text: result.Message}, + } + + if result.Location != nil { + fqn := result.Location.Module + if result.Location.Function != "" { + fqn += "." + result.Location.Function + } + res.LogicalLocations = []logicalLocationV21{ + { + FullyQualifiedName: fqn, + Kind: "function", + }, + } + } + + run.Results = append(run.Results, res) + } + + return run +} +``` + +### Step 4: Run tests to verify they pass + +Run: `go test ./internal/sarif/... -v` +Expected: PASS (v2.1.0 tests pass, version registry tests now have 1 version) + +### Step 5: Commit + +```bash +git add internal/sarif/v21.go internal/sarif/v21_test.go +git commit -m "feat(sarif): add SARIF v2.1.0 serializer" +``` + +--- + +## Task 6: SARIF v2.2 Serializer + +**Files:** + +- Create: `internal/sarif/v22.go` +- Create: `internal/sarif/v22_test.go` + +### Step 1: Write failing tests for v2.2 serializer + +```go +// internal/sarif/v22_test.go +package sarif + +import ( + "encoding/json" + "testing" +) + +func TestSerializeV22_Schema(t *testing.T) { + report := &Report{ + ToolName: "test-tool", + ToolInfoURI: "https://example.com", + } + + data, err := Serialize(report, Version22, 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://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.2/schema/sarif-2.2.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: "test-failure", Description: "Test failure"}, + }, + Results: []Result{ + { + RuleID: "test-failure", + Level: "error", + Message: "TestFoo failed", + Location: &LogicalLocation{ + Module: "example.com/foo", + Function: "TestFoo", + }, + }, + }, + } + + data, err := Serialize(report, Version22, 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{}) + + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + + res := results[0].(map[string]interface{}) + if res["ruleId"] != "test-failure" { + t.Errorf("ruleId = %v, want %v", res["ruleId"], "test-failure") + } +} +``` + +### Step 2: Run tests to verify they fail + +Run: `go test ./internal/sarif/... -v -run V22` +Expected: FAIL - v2.2 serializer not registered + +### Step 3: Write implementation + +```go +// internal/sarif/v22.go +package sarif + +import "encoding/json" + +func init() { + Register(Version22, serializeV22) +} + +// SARIF v2.2 JSON structures +// v2.2 is structurally similar to v2.1.0 with minor additions + +type sarifV22 struct { + Schema string `json:"$schema"` + Version string `json:"version"` + Runs []runV22 `json:"runs"` +} + +type runV22 struct { + Tool toolV22 `json:"tool"` + Results []resultV22 `json:"results"` +} + +type toolV22 struct { + Driver driverV22 `json:"driver"` +} + +type driverV22 struct { + Name string `json:"name"` + InformationURI string `json:"informationUri,omitempty"` + Rules []ruleV22 `json:"rules,omitempty"` +} + +type ruleV22 struct { + ID string `json:"id"` + ShortDescription messageV22 `json:"shortDescription,omitempty"` +} + +type resultV22 struct { + RuleID string `json:"ruleId"` + Level string `json:"level"` + Message messageV22 `json:"message"` + LogicalLocations []logicalLocationV22 `json:"logicalLocations,omitempty"` +} + +type messageV22 struct { + Text string `json:"text"` +} + +type logicalLocationV22 struct { + FullyQualifiedName string `json:"fullyQualifiedName,omitempty"` + Kind string `json:"kind,omitempty"` +} + +func serializeV22(r *Report) ([]byte, error) { + doc := sarifV22{ + Schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.2/schema/sarif-2.2.json", + Version: "2.2", + Runs: []runV22{buildRunV22(r)}, + } + return json.Marshal(doc) +} + +func buildRunV22(r *Report) runV22 { + run := runV22{ + Tool: toolV22{ + Driver: driverV22{ + Name: r.ToolName, + InformationURI: r.ToolInfoURI, + }, + }, + Results: make([]resultV22, 0, len(r.Results)), + } + + // Add rules + for _, rule := range r.Rules { + run.Tool.Driver.Rules = append(run.Tool.Driver.Rules, ruleV22{ + ID: rule.ID, + ShortDescription: messageV22{Text: rule.Description}, + }) + } + + // Add results + for _, result := range r.Results { + res := resultV22{ + RuleID: result.RuleID, + Level: result.Level, + Message: messageV22{Text: result.Message}, + } + + if result.Location != nil { + fqn := result.Location.Module + if result.Location.Function != "" { + fqn += "." + result.Location.Function + } + res.LogicalLocations = []logicalLocationV22{ + { + FullyQualifiedName: fqn, + Kind: "function", + }, + } + } + + run.Results = append(run.Results, res) + } + + return run +} +``` + +### Step 4: Run all SARIF tests to verify they pass + +Run: `go test ./internal/sarif/... -v` +Expected: All PASS (including version registry tests now with 2 versions) + +### Step 5: Commit + +```bash +git add internal/sarif/v22.go internal/sarif/v22_test.go +git commit -m "feat(sarif): add SARIF v2.2 serializer" +``` + +--- + +## Task 7: Pretty Print Test + +**Files:** + +- Modify: `internal/sarif/version_test.go` + +### Step 1: Add test for pretty printing + +```go +// Add to internal/sarif/version_test.go + +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") + } +} +``` + +### Step 2: Add import + +Add `"bytes"` to the imports in version_test.go. + +### Step 3: Run test to verify it passes + +Run: `go test ./internal/sarif/... -v -run Pretty` +Expected: PASS + +### Step 4: Commit + +```bash +git add internal/sarif/version_test.go +git commit -m "test(sarif): add pretty print test" +``` + +--- + +## Task 8: Update Converter + +**Files:** + +- Modify: `internal/converter.go` +- Modify: `internal/converter_test.go` + +### Step 1: Update converter to use new packages + +```go +// internal/converter.go +package internal + +import ( + "fmt" + "os" + + "github.com/ivuorinen/go-test-sarif-action/internal/sarif" + "github.com/ivuorinen/go-test-sarif-action/internal/testjson" +) + +// ConvertOptions configures the conversion behavior. +type ConvertOptions struct { + SARIFVersion sarif.Version + Pretty bool +} + +// DefaultConvertOptions returns options with sensible defaults. +func DefaultConvertOptions() ConvertOptions { + return ConvertOptions{ + SARIFVersion: sarif.DefaultVersion, + Pretty: false, + } +} + +// 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 + } + + // Build internal SARIF model + report := buildReport(events) + + // Serialize to requested version + data, err := sarif.Serialize(report, opts.SARIFVersion, opts.Pretty) + if err != nil { + return err + } + + // 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, + } + if e.Package != "" || e.Test != "" { + result.Location = &sarif.LogicalLocation{ + Module: e.Package, + Function: e.Test, + } + } + report.Results = append(report.Results, result) + } + } + + return report +} +``` + +### Step 2: Update existing tests + +```go +// internal/converter_test.go +package internal + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ivuorinen/go-test-sarif-action/internal/sarif" +) + +func TestConvertToSARIF_Success(t *testing.T) { + 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 { + t.Fatalf("Failed to write input file: %v", err) + } + + outputPath := filepath.Join(dir, "output.sarif") + + opts := DefaultConvertOptions() + if err := ConvertToSARIF(inputPath, outputPath, opts); 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") + } +} + +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, "output.sarif") + + 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, "output.sarif") + + 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) { + 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") + + opts := DefaultConvertOptions() + if err := ConvertToSARIF(inputPath, outputPath, opts); err != nil { + t.Errorf("ConvertToSARIF returned an error: %v", err) + } + + data, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("Failed to read SARIF output file: %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, "input.json") + 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) + } + }) + } +} +``` + +### Step 3: Run tests to verify they pass + +Run: `go test ./internal/... -v` +Expected: All PASS + +### Step 4: Commit + +```bash +git add internal/converter.go internal/converter_test.go +git commit -m "refactor(converter): use internal sarif and testjson packages" +``` + +--- + +## Task 9: Update CLI + +**Files:** + +- Modify: `cmd/main.go` +- Modify: `cmd/main_test.go` + +### Step 1: Update main.go with new flags + +```go +// cmd/main.go +package main + +import ( + "flag" + "fmt" + "io" + "os" + "strings" + + "github.com/ivuorinen/go-test-sarif-action/internal" + "github.com/ivuorinen/go-test-sarif-action/internal/sarif" +) + +var ( + version = "dev" + commit = "none" + date = "unknown" + builtBy = "unknown" +) + +func printVersion(w io.Writer) { + _, _ = fmt.Fprintf(w, "go-test-sarif %s\n", version) + _, _ = fmt.Fprintf(w, " commit: %s\n", commit) + _, _ = fmt.Fprintf(w, " built at: %s\n", date) + _, _ = fmt.Fprintf(w, " built by: %s\n", builtBy) +} + +func printUsage(w io.Writer) { + _, _ = fmt.Fprintln(w, "Usage: go-test-sarif [options] ") + _, _ = fmt.Fprintln(w, " go-test-sarif --version") + _, _ = fmt.Fprintln(w, "") + _, _ = fmt.Fprintln(w, "Options:") + _, _ = fmt.Fprintf(w, " --sarif-version string SARIF version (%s) (default %q)\n", + strings.Join(sarif.SupportedVersions(), ", "), sarif.DefaultVersion) + _, _ = fmt.Fprintln(w, " --pretty Pretty-print JSON output") + _, _ = fmt.Fprintln(w, " -v, --version Display version information") +} + +func run(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("go-test-sarif", flag.ContinueOnError) + fs.SetOutput(stderr) + + var ( + versionFlag bool + sarifVersion string + prettyOutput bool + ) + + fs.BoolVar(&versionFlag, "version", false, "Display version information") + fs.BoolVar(&versionFlag, "v", false, "Display version information (short)") + fs.StringVar(&sarifVersion, "sarif-version", string(sarif.DefaultVersion), + fmt.Sprintf("SARIF version (%s)", strings.Join(sarif.SupportedVersions(), ", "))) + fs.BoolVar(&prettyOutput, "pretty", false, "Pretty-print JSON output") + + if err := fs.Parse(args[1:]); err != nil { + return 1 + } + + if versionFlag { + printVersion(stdout) + return 0 + } + + if fs.NArg() < 2 { + printUsage(stderr) + return 1 + } + + inputFile := fs.Arg(0) + outputFile := fs.Arg(1) + + opts := internal.ConvertOptions{ + SARIFVersion: sarif.Version(sarifVersion), + Pretty: prettyOutput, + } + + if err := internal.ConvertToSARIF(inputFile, outputFile, opts); err != nil { + _, _ = fmt.Fprintf(stderr, "Error: %v\n", err) + return 1 + } + + return 0 +} + +func main() { + os.Exit(run(os.Args, os.Stdout, os.Stderr)) +} +``` + +### Step 2: Update main_test.go + +```go +// cmd/main_test.go +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRun(t *testing.T) { + tests := []struct { + name string + args []string + setupFunc func() (string, string, func()) + wantExit int + wantStdout string + wantStderr string + }{ + { + name: "version flag long", + args: []string{"go-test-sarif", "--version"}, + wantExit: 0, + wantStdout: "go-test-sarif dev", + }, + { + name: "version flag short", + args: []string{"go-test-sarif", "-v"}, + wantExit: 0, + wantStdout: "go-test-sarif dev", + }, + { + name: "missing arguments", + args: []string{"go-test-sarif"}, + wantExit: 1, + wantStderr: "Usage: go-test-sarif", + }, + { + name: "only one argument", + args: []string{"go-test-sarif", "input.json"}, + wantExit: 1, + wantStderr: "Usage: go-test-sarif", + }, + { + name: "valid conversion", + args: []string{"go-test-sarif", "input.json", "output.sarif"}, + setupFunc: setupValidTestFiles, + wantExit: 0, + }, + { + name: "invalid input file", + args: []string{"go-test-sarif", "nonexistent.json", "output.sarif"}, + wantExit: 1, + wantStderr: "Error:", + }, + { + name: "invalid flag", + args: []string{"go-test-sarif", "--invalid"}, + wantExit: 1, + wantStderr: "flag provided but not defined", + }, + { + name: "with sarif-version flag", + args: []string{"go-test-sarif", "--sarif-version", "2.2", "input.json", "output.sarif"}, + setupFunc: setupValidTestFiles, + wantExit: 0, + }, + { + name: "with pretty flag", + args: []string{"go-test-sarif", "--pretty", "input.json", "output.sarif"}, + setupFunc: setupValidTestFiles, + wantExit: 0, + }, + { + name: "invalid sarif version", + args: []string{"go-test-sarif", "--sarif-version", "9.9.9", "input.json", "output.sarif"}, + setupFunc: setupValidTestFiles, + wantExit: 1, + wantStderr: "Error:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var cleanup func() + args := make([]string, len(tt.args)) + copy(args, tt.args) + + if tt.setupFunc != nil { + inputFile, outputFile, cleanupFunc := tt.setupFunc() + cleanup = cleanupFunc + for i, arg := range args { + switch arg { + case "input.json": + args[i] = inputFile + case "output.sarif": + args[i] = outputFile + } + } + } + if cleanup != nil { + defer cleanup() + } + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + exitCode := run(args, stdout, stderr) + + if exitCode != tt.wantExit { + t.Errorf("run() exit code = %v, want %v\nstderr: %s", exitCode, tt.wantExit, stderr.String()) + } + + if tt.wantStdout != "" && !strings.Contains(stdout.String(), tt.wantStdout) { + t.Errorf("stdout = %q, want to contain %q", stdout.String(), tt.wantStdout) + } + + if tt.wantStderr != "" && !strings.Contains(stderr.String(), tt.wantStderr) { + t.Errorf("stderr = %q, want to contain %q", stderr.String(), tt.wantStderr) + } + }) + } +} + +func TestPrintVersion(t *testing.T) { + buf := &bytes.Buffer{} + printVersion(buf) + + output := buf.String() + if !strings.Contains(output, "go-test-sarif dev") { + t.Errorf("printVersion() = %q, want to contain %q", output, "go-test-sarif dev") + } + if !strings.Contains(output, "commit: none") { + t.Errorf("printVersion() = %q, want to contain %q", output, "commit: none") + } +} + +func TestPrintUsage(t *testing.T) { + buf := &bytes.Buffer{} + printUsage(buf) + + output := buf.String() + if !strings.Contains(output, "Usage: go-test-sarif") { + t.Errorf("printUsage() = %q, want to contain usage information", output) + } + if !strings.Contains(output, "--sarif-version") { + t.Errorf("printUsage() = %q, want to contain --sarif-version flag", output) + } + if !strings.Contains(output, "--pretty") { + t.Errorf("printUsage() = %q, want to contain --pretty flag", output) + } +} + +func setupValidTestFiles() (string, string, func()) { + tmpDir, err := os.MkdirTemp("", "go-test-sarif-test") + if err != nil { + panic(err) + } + + inputFile := filepath.Join(tmpDir, "test-input.json") + outputFile := filepath.Join(tmpDir, "test-output.sarif") + + testJSON := `{"Time":"2023-01-01T00:00:00Z","Action":"pass","Package":"example.com/test","Test":"TestExample","Elapsed":0.1}` + if err := os.WriteFile(inputFile, []byte(testJSON), 0o644); err != nil { + panic(err) + } + + cleanup := func() { + _ = os.RemoveAll(tmpDir) + } + + return inputFile, outputFile, cleanup +} +``` + +### Step 3: Run all tests to verify they pass + +Run: `go test ./... -v` +Expected: All PASS + +### Step 4: Commit + +```bash +git add cmd/main.go cmd/main_test.go +git commit -m "feat(cli): add --sarif-version and --pretty flags" +``` + +--- + +## Task 10: Remove go-sarif Dependency + +**Files:** + +- Modify: `go.mod` + +### Step 1: Run go mod tidy to clean up dependencies + +Run: `go mod tidy` + +### Step 2: Verify go-sarif is removed + +Run: `go mod graph | grep sarif` +Expected: No output (go-sarif removed) + +### Step 3: Verify yaml.v3 is removed + +Run: `go mod graph | grep yaml` +Expected: No output (yaml.v3 removed) + +### Step 4: Run full test suite + +Run: `go test ./... -v` +Expected: All PASS + +### Step 5: Verify build works + +Run: `go build ./cmd/...` +Expected: Success + +### Step 6: Commit + +```bash +git add go.mod go.sum +git commit -m "chore: remove go-sarif dependency + +Replaced external go-sarif library with internal implementation. +This eliminates the transitive dependency on gopkg.in/yaml.v3." +``` + +--- + +## Task 11: Final Verification + +### Step 1: Verify no external dependencies remain + +Run: `go list -m all | wc -l` +Expected: 1 (only the main module) + +### Step 2: Run tests with race detector + +Run: `go test -race ./...` +Expected: PASS + +### Step 3: Verify binary works end-to-end + +```bash +# Build +go build -o go-test-sarif ./cmd/ + +# Create test input +echo '{"Action":"fail","Package":"example.com/test","Test":"TestFail","Output":"assertion failed"}' > /tmp/test.json + +# Run with default version +./go-test-sarif /tmp/test.json /tmp/output-v21.sarif + +# Run with v2.2 +./go-test-sarif --sarif-version 2.2 /tmp/test.json /tmp/output-v22.sarif + +# Run with pretty print +./go-test-sarif --pretty /tmp/test.json /tmp/output-pretty.sarif + +# Verify outputs exist and contain expected content +cat /tmp/output-v21.sarif | head -5 +cat /tmp/output-v22.sarif | head -5 +cat /tmp/output-pretty.sarif | head -10 + +# Cleanup +rm -f go-test-sarif /tmp/test.json /tmp/output-*.sarif +``` + +### Step 4: Verify design document alignment + +Both design and implementation documents reference SARIF v2.1.0 and v2.2. + +--- + +## Summary + +After completing all tasks: + +- **Zero external dependencies** - Only Go standard library +- **No yaml.v3** in dependency graph +- **SARIF v2.1.0 and v2.2** support with extensible registry +- **New CLI flags**: `--sarif-version`, `--pretty` +- **Logical locations** included in results (package + test name) +- **All fields captured** from `go test -json` for future use diff --git a/go.mod b/go.mod index 45bc3d1..7e7d8c3 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,3 @@ module github.com/ivuorinen/go-test-sarif-action go 1.25.3 - -require github.com/owenrumney/go-sarif/v2 v2.3.3 - -require gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index f299e58..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,35 +0,0 @@ -github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/owenrumney/go-sarif v1.1.1/go.mod h1:dNDiPlF04ESR/6fHlPyq7gHKmrM0sHUvAGjsoh8ZH0U= -github.com/owenrumney/go-sarif/v2 v2.3.3 h1:ubWDJcF5i3L/EIOER+ZyQ03IfplbSU1BLOE26uKQIIU= -github.com/owenrumney/go-sarif/v2 v2.3.3/go.mod h1:MSqMMx9WqlBSY7pXoOZWgEsVB4FDNfhcaXDA1j6Sr+w= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4= -github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= -github.com/zclconf/go-cty v1.10.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/converter.go b/internal/converter.go index b7e30c5..53e4014 100644 --- a/internal/converter.go +++ b/internal/converter.go @@ -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 +} diff --git a/internal/converter_test.go b/internal/converter_test.go index ee01f78..fe2d258 100644 --- a/internal/converter_test.go +++ b/internal/converter_test.go @@ -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) + } + }) + } +} diff --git a/internal/sarif/model.go b/internal/sarif/model.go new file mode 100644 index 0000000..dd5e1e7 --- /dev/null +++ b/internal/sarif/model.go @@ -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 +} diff --git a/internal/sarif/model_test.go b/internal/sarif/model_test.go new file mode 100644 index 0000000..b4773ff --- /dev/null +++ b/internal/sarif/model_test.go @@ -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) + } +} diff --git a/internal/sarif/serialize.go b/internal/sarif/serialize.go new file mode 100644 index 0000000..ad2ab77 --- /dev/null +++ b/internal/sarif/serialize.go @@ -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 +} diff --git a/internal/sarif/v21.go b/internal/sarif/v21.go new file mode 100644 index 0000000..6f5e4b2 --- /dev/null +++ b/internal/sarif/v21.go @@ -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") +} diff --git a/internal/sarif/v21_test.go b/internal/sarif/v21_test.go new file mode 100644 index 0000000..8c73e4f --- /dev/null +++ b/internal/sarif/v21_test.go @@ -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") + } +} diff --git a/internal/sarif/v22.go b/internal/sarif/v22.go new file mode 100644 index 0000000..e3db5b9 --- /dev/null +++ b/internal/sarif/v22.go @@ -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") +} diff --git a/internal/sarif/v22_test.go b/internal/sarif/v22_test.go new file mode 100644 index 0000000..65bb5b4 --- /dev/null +++ b/internal/sarif/v22_test.go @@ -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) + } +} diff --git a/internal/sarif/version.go b/internal/sarif/version.go new file mode 100644 index 0000000..c56da94 --- /dev/null +++ b/internal/sarif/version.go @@ -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 +} diff --git a/internal/sarif/version_test.go b/internal/sarif/version_test.go new file mode 100644 index 0000000..6bad2ba --- /dev/null +++ b/internal/sarif/version_test.go @@ -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") + } +} diff --git a/internal/testjson/parser.go b/internal/testjson/parser.go new file mode 100644 index 0000000..378ee30 --- /dev/null +++ b/internal/testjson/parser.go @@ -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 +} diff --git a/internal/testjson/parser_test.go b/internal/testjson/parser_test.go new file mode 100644 index 0000000..86042a7 --- /dev/null +++ b/internal/testjson/parser_test.go @@ -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") + } +} diff --git a/internal/testutil/constants.go b/internal/testutil/constants.go new file mode 100644 index 0000000..7374b70 --- /dev/null +++ b/internal/testutil/constants.go @@ -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" +)