From e7ad11395ab1ff579b9b8afe817d54c2f436ff3e Mon Sep 17 00:00:00 2001 From: Ismo Vuorinen Date: Wed, 21 Jan 2026 02:16:05 +0200 Subject: [PATCH] feat: replace go-sarif with internal SARIF implementation (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add design for replacing go-sarif with internal implementation Removes external dependency chain (go-sarif → testify → yaml.v3) by implementing a minimal internal SARIF package with support for v2.1.0 and v3.0 output formats. * docs: add implementation plan for replacing go-sarif 11 tasks covering: - testjson parser with all 7 fields - internal SARIF model and version registry - v2.1.0 and v2.2 serializers - converter refactoring - CLI with --sarif-version and --pretty flags - dependency cleanup * feat(testjson): add parser for go test -json output Add TestEvent struct with all 7 fields from go test -json and ParseFile function to parse test output files line by line. * test(testjson): add tests for all fields, malformed JSON, file not found * feat(sarif): add internal SARIF data model * feat(sarif): add version registry (serializers pending) Add version registry infrastructure for SARIF serialization: - Version type and constants (2.1.0, 2.2) - DefaultVersion set to 2.1.0 - Register() function for version-specific serializers - Serialize() function with pretty-print support - SupportedVersions() for listing registered versions Note: TestSupportedVersions will fail until serializers are registered via init() functions in v21.go and v22.go (Tasks 5/6). * feat(sarif): add SARIF v2.1.0 serializer * feat(sarif): add SARIF v2.2 serializer * test(sarif): add pretty print test * refactor(converter): use internal sarif and testjson packages Replace external go-sarif library with internal packages: - Use internal/sarif for SARIF model and serialization - Use internal/testjson for Go test JSON parsing - Add ConvertOptions struct with SARIFVersion and Pretty options - Remove external github.com/owenrumney/go-sarif/v2 dependency * feat(cli): add --sarif-version and --pretty flags * fix(lint): resolve golangci-lint errors Fix errcheck violation by properly handling f.Close() error return value and add proper package comments to satisfy revive linter. * refactor: consolidate SARIF serializers and fix code issues - Extract shared SARIF types and serialization logic into serialize.go - Simplify v21.go and v22.go to thin wrappers (107/108 → 12 lines each) - Add testConvertHelper() to reduce test duplication in converter_test.go - Remove redundant nested check in converter.go (outer condition already guarantees non-empty) - Fix LogicalLocations: avoid leading dot when Module is empty, set Kind correctly - Increase scanner buffer in parser.go from 64KB to 4MB for large JSON lines - Extract test constants to reduce string literal duplication * docs: fix SARIF v3.0 references to v2.2 SARIF v3.0 doesn't exist. Update design and implementation docs to correctly reference v2.1.0 and v2.2 throughout. * fix: update SARIF 2.2 schema URL and add markdown language identifiers Update schema URL to point to accessible prerelease location and add language identifiers to fenced code blocks to resolve MD040 violations. * chore: add pre-commit config and fix config file formatting Add pre-commit configuration and fix formatting issues in config files including trailing whitespace, YAML document markers, and JSON formatting. * docs: fix markdown linting issues in implementation plan - Convert bold step markers to proper headings - Fix line length violations in header section - Add markdownlint config to allow duplicate sibling headings - Add ecrc config to exclude plan docs from editorconfig checking * chore: update MegaLinter configuration Configure specific linters and add exclusion patterns for test data and generated files. * docs: fix filename in design doc (writer.go → serialize.go) * refactor(tests): fix SonarCloud code quality issues Extract helper functions to reduce cognitive complexity in TestRun and consolidate duplicate string literals into shared testutil package. * docs: add docstrings to exported variables and struct fields Add documentation comments to reach ~98% docstring coverage: - cmd/main.go: document build-time variables - internal/converter.go: document ConvertOptions fields - internal/sarif/model.go: document Report, Rule, Result, LogicalLocation fields - internal/testjson/parser.go: document TestEvent fields * fix(testjson): change FailedBuild field type from string to bool The go test -json output emits FailedBuild as a boolean, not a string. This was causing JSON unmarshal errors. Updated the struct field type and corresponding test assertions. * chore(ci): mega-linter config tweaks for revive --- .ecrc | 5 + .editorconfig | 1 - .github/renovate.json | 4 +- .golangci.yml | 1 + .goreleaser.yml | 9 +- .markdownlint.json | 10 + .mega-linter.yml | 40 +- .pre-commit-config.yaml | 79 + cmd/main.go | 37 +- cmd/main_test.go | 157 +- .../2026-01-20-replace-go-sarif-design.md | 230 +++ ...6-01-20-replace-go-sarif-implementation.md | 1718 +++++++++++++++++ go.mod | 4 - go.sum | 35 - internal/converter.go | 88 +- internal/converter_test.go | 187 +- internal/sarif/model.go | 42 + internal/sarif/model_test.go | 43 + internal/sarif/serialize.go | 107 + internal/sarif/v21.go | 12 + internal/sarif/v21_test.go | 127 ++ internal/sarif/v22.go | 12 + internal/sarif/v22_test.go | 74 + internal/sarif/version.go | 66 + internal/sarif/version_test.go | 83 + internal/testjson/parser.go | 60 + internal/testjson/parser_test.go | 130 ++ internal/testutil/constants.go | 16 + 28 files changed, 3157 insertions(+), 220 deletions(-) create mode 100644 .ecrc create mode 100644 .markdownlint.json create mode 100644 .pre-commit-config.yaml create mode 100644 docs/plans/2026-01-20-replace-go-sarif-design.md create mode 100644 docs/plans/2026-01-20-replace-go-sarif-implementation.md create mode 100644 internal/sarif/model.go create mode 100644 internal/sarif/model_test.go create mode 100644 internal/sarif/serialize.go create mode 100644 internal/sarif/v21.go create mode 100644 internal/sarif/v21_test.go create mode 100644 internal/sarif/v22.go create mode 100644 internal/sarif/v22_test.go create mode 100644 internal/sarif/version.go create mode 100644 internal/sarif/version_test.go create mode 100644 internal/testjson/parser.go create mode 100644 internal/testjson/parser_test.go create mode 100644 internal/testutil/constants.go 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" +)