buildifier: add SARIF output (--format=sarif) (#1529)

Adds SARIF 2.1.0 as a third diagnostics format next to `text` and
`json`. SARIF is understood by many CI, IDE and code-quality tools.
Fixes #1528.

## Usage

```
buildifier --mode=check --lint=warn --format=sarif -r . > buildifier.sarif
```

As with `json`, `--format=sarif` only works with `--mode=check`, and
`-v` indents the output.

## Mapping

- Each lint warning becomes a result at level `warning`. Its `ruleId` is
the warning category, `helpUri` is the WARNINGS.md link, and the region
is the start and end line and column.
- A file that needs reformatting becomes a file-level result (no region)
with rule `format`, level `warning`.
- A file that can't be parsed becomes a file-level result with rule
`parse-error`, level `error`.
- Rules are deduplicated per category.
- With no findings, the output is still a valid log with empty `rules`
and `results`.

## Design notes

- The serializer is a small in-tree struct set, so it adds no
dependency.
- Findings carry no severity today (only `actionable`, which `warn.go`
always sets to `true`). So every lint finding is a `warning`.
- `autoFixable` is not mapped. It would need SARIF `fixes` with
replacement text, which can come in a follow-up.
- A `format` finding has no region, because buildifier doesn't track
where the formatting differs.
- `artifactLocation.uri` is the path as passed on the command line, so
run from the repo root with relative paths for repo-relative URIs.

## Commits

1. Serializer (`utils/sarif.go`), unit tests, and the `Format` hook-up.
2. Accept `--format=sarif` in `--mode=check` and update the help text.
3. README section.
4. Integration tests with goldens, covering many files, a single file,
stdin and an invalid file.

## Additional testing

- Sample outputs validate against the official OASIS SARIF 2.1.0 schema.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
diff --git a/buildifier/README.md b/buildifier/README.md
index 875058e..b3a3a7c 100644
--- a/buildifier/README.md
+++ b/buildifier/README.md
@@ -235,6 +235,23 @@
 failures or wrong input parameters, this means the output can be parsed as JSON, and its `success`
 field should be used to determine whether the diagnostics result is positive.
 
+## File diagnostics in SARIF
+
+Buildifier can also emit its findings as a [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html)
+report, triggered by `--format=sarif` (only works in combination with `--mode=check`). If used in
+combination with `-v`, the output will be indented.
+
+* Every lint warning becomes a result whose `ruleId` is the warning category, with a region
+  (line and column) and level `warning`.
+* A file that is not correctly formatted yields a file-level result (no region) with rule `format`.
+* A file that cannot be parsed yields a result with level `error` and rule `parse-error`.
+
+Example:
+
+```bash
+buildifier --mode=check --lint=warn --format=sarif -r . > buildifier.sarif
+```
+
 ## Symlink Safety
 
 By default, Buildifier will not write to symlinks pointing outside of the Bazel workspace to prevent unintended path traversal.
diff --git a/buildifier/config/config.go b/buildifier/config/config.go
index 7b90929..4efa191 100644
--- a/buildifier/config/config.go
+++ b/buildifier/config/config.go
@@ -97,7 +97,7 @@
 	// repo (for REPO.bazel files), vendor (for VENDOR.bazel files),
 	// or auto (default, based on the filename)
 	InputType string `json:"type,omitempty"`
-	// Format sets the diagnostics format: text or json (default text)
+	// Format sets the diagnostics format: text, json or sarif (default text)
 	Format string `json:"format,omitempty"`
 	// Mode determines the formatting mode: check, diff, or fix (default fix)
 	Mode string `json:"mode,omitempty"`
@@ -179,7 +179,7 @@
 	flags.BoolVar(&c.Recursive, "r", c.Recursive, "find starlark files recursively")
 	flags.BoolVar(&c.MultiDiff, "multi_diff", c.MultiDiff, "the command specified by the -diff_command flag can diff multiple files in the style of tkdiff (default false)")
 	flags.StringVar(&c.Mode, "mode", c.Mode, "formatting mode: check, diff, or fix (default fix)")
-	flags.StringVar(&c.Format, "format", c.Format, "diagnostics format: text or json (default text)")
+	flags.StringVar(&c.Format, "format", c.Format, "diagnostics format: text, json or sarif (default text)")
 	flags.StringVar(&c.DiffCommand, "diff_command", c.DiffCommand, "command to run when the formatting mode is diff (default uses the BUILDIFIER_DIFF, BUILDIFIER_MULTIDIFF, and DISPLAY environment variables to create the diff command)")
 	flags.StringVar(&c.Lint, "lint", c.Lint, "lint mode: off, warn, or fix (default off)")
 	flags.StringVar(&c.Warnings, "warnings", c.Warnings, "comma-separated warnings used in the lint mode or \"all\"")
diff --git a/buildifier/config/config_test.go b/buildifier/config/config_test.go
index a9b0877..c736433 100644
--- a/buildifier/config/config_test.go
+++ b/buildifier/config/config_test.go
@@ -160,7 +160,7 @@
 	// d: alias for -mode=diff ("false")
 	// diff_command: command to run when the formatting mode is diff (default uses the BUILDIFIER_DIFF, BUILDIFIER_MULTIDIFF, and DISPLAY environment variables to create the diff command) ("")
 	// disable_symlink_safety: per default Buildifier will not write to symlinks pointing outside of the Bazel workspace. Setting this to true will disable this behavior ("false")
-	// format: diagnostics format: text or json (default text) ("")
+	// format: diagnostics format: text, json or sarif (default text) ("")
 	// help: print usage information ("false")
 	// lint: lint mode: off, warn, or fix (default off) ("")
 	// mode: formatting mode: check, diff, or fix (default fix) ("")
@@ -256,8 +256,10 @@
 		"lint fix error":        {options: "--lint=fix --mode=check", wantErr: fmt.Errorf("--lint=fix is only compatible with --mode=fix")},
 		"format mode error":     {options: "--mode=fix --format=text", wantErr: fmt.Errorf("cannot specify --format without --mode=check")},
 		"format text":           {options: "--mode=check --format=text"},
+		"format sarif":          {options: "--mode=check --format=sarif"},
+		"format sarif error":    {options: "--mode=fix --format=sarif", wantErr: fmt.Errorf("cannot specify --format without --mode=check")},
 		"format json":           {options: "--mode=check --format=json"},
-		"format error":          {options: "--mode=check --format=foo", wantErr: fmt.Errorf("unrecognized format foo; valid types are text, json")},
+		"format error":          {options: "--mode=check --format=foo", wantErr: fmt.Errorf("unrecognized format foo; valid types are text, json, sarif")},
 		"type build":            {options: "--type=build"},
 		"type bzl":              {options: "--type=bzl"},
 		"type workspace":        {options: "--type=workspace"},
diff --git a/buildifier/config/validation.go b/buildifier/config/validation.go
index 509be21..eb3bdb7 100644
--- a/buildifier/config/validation.go
+++ b/buildifier/config/validation.go
@@ -40,13 +40,13 @@
 	case "":
 		return nil
 
-	case "text", "json":
+	case "text", "json", "sarif":
 		if *mode != "check" {
 			return fmt.Errorf("cannot specify --format without --mode=check")
 		}
 
 	default:
-		return fmt.Errorf("unrecognized format %s; valid types are text, json", *format)
+		return fmt.Errorf("unrecognized format %s; valid types are text, json, sarif", *format)
 	}
 	return nil
 }
diff --git a/buildifier/integration_test.sh b/buildifier/integration_test.sh
index d4cc289..e7ae2e7 100755
--- a/buildifier/integration_test.sh
+++ b/buildifier/integration_test.sh
@@ -507,7 +507,7 @@
 test_lint "cfg" "--warnings=attr-cfg" "test_dir/fixed_golden_cfg.bzl" "$error_cfg" 0
 test_lint "custom" "--warnings=-bzl-visibility,-integer-division,+unsorted-dict-items" "test_dir/fixed_golden_dict_cfg.bzl" "$error_docstring"$'\n'"$error_dict"$'\n'"$error_cfg" 1
 
-# Test --format=json
+# Test --format=json and --format=sarif
 
 mkdir test_dir/json
 cp test_dir/to_fix.bzl test_dir/json
@@ -658,6 +658,259 @@
 }
 EOF
 
+cat > golden/sarif_report_golden <<'EOF'
+{
+    "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
+    "version": "2.1.0",
+    "runs": [
+        {
+            "tool": {
+                "driver": {
+                    "name": "buildifier",
+                    "informationUri": "https://github.com/bazel-contrib/buildtools/tree/main/buildifier",
+                    "rules": [
+                        {
+                            "id": "bzl-visibility",
+                            "helpUri": "https://github.com/bazel-contrib/buildtools/blob/main/WARNINGS.md#bzl-visibility"
+                        },
+                        {
+                            "id": "integer-division",
+                            "helpUri": "https://github.com/bazel-contrib/buildtools/blob/main/WARNINGS.md#integer-division"
+                        },
+                        {
+                            "id": "attr-cfg",
+                            "helpUri": "https://github.com/bazel-contrib/buildtools/blob/main/WARNINGS.md#attr-cfg"
+                        },
+                        {
+                            "id": "format",
+                            "shortDescription": {
+                                "text": "The file is not formatted according to buildifier's rules."
+                            }
+                        }
+                    ]
+                }
+            },
+            "columnKind": "unicodeCodePoints",
+            "results": [
+                {
+                    "ruleId": "bzl-visibility",
+                    "ruleIndex": 0,
+                    "level": "warning",
+                    "message": {
+                        "text": "Module \"//foo/bar/internal/baz:module.bzl\" can only be loaded from files located inside \"//foo/bar\", not from \"//json/to_fix.bzl\"."
+                    },
+                    "locations": [
+                        {
+                            "physicalLocation": {
+                                "artifactLocation": {
+                                    "uri": "to_fix.bzl"
+                                },
+                                "region": {
+                                    "startLine": 1,
+                                    "startColumn": 6,
+                                    "endLine": 1,
+                                    "endColumn": 41
+                                }
+                            }
+                        }
+                    ]
+                },
+                {
+                    "ruleId": "integer-division",
+                    "ruleIndex": 1,
+                    "level": "warning",
+                    "message": {
+                        "text": "The \"/\" operator for integer division is deprecated in favor of \"//\"."
+                    },
+                    "locations": [
+                        {
+                            "physicalLocation": {
+                                "artifactLocation": {
+                                    "uri": "to_fix.bzl"
+                                },
+                                "region": {
+                                    "startLine": 4,
+                                    "startColumn": 5,
+                                    "endLine": 4,
+                                    "endColumn": 10
+                                }
+                            }
+                        }
+                    ]
+                },
+                {
+                    "ruleId": "attr-cfg",
+                    "ruleIndex": 2,
+                    "level": "warning",
+                    "message": {
+                        "text": "cfg = \"data\" for attr definitions has no effect and should be removed."
+                    },
+                    "locations": [
+                        {
+                            "physicalLocation": {
+                                "artifactLocation": {
+                                    "uri": "to_fix.bzl"
+                                },
+                                "region": {
+                                    "startLine": 6,
+                                    "startColumn": 15,
+                                    "endLine": 6,
+                                    "endColumn": 27
+                                }
+                            }
+                        }
+                    ]
+                },
+                {
+                    "ruleId": "format",
+                    "ruleIndex": 3,
+                    "level": "warning",
+                    "message": {
+                        "text": "The file needs to be reformatted."
+                    },
+                    "locations": [
+                        {
+                            "physicalLocation": {
+                                "artifactLocation": {
+                                    "uri": "to_fix_2.bzl"
+                                }
+                            }
+                        }
+                    ]
+                },
+                {
+                    "ruleId": "format",
+                    "ruleIndex": 3,
+                    "level": "warning",
+                    "message": {
+                        "text": "The file needs to be reformatted."
+                    },
+                    "locations": [
+                        {
+                            "physicalLocation": {
+                                "artifactLocation": {
+                                    "uri": "to_fix_3.bzl"
+                                }
+                            }
+                        }
+                    ]
+                }
+            ]
+        }
+    ]
+}
+EOF
+
+cat > golden/sarif_report_small_golden <<'EOF'
+{
+    "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
+    "version": "2.1.0",
+    "runs": [
+        {
+            "tool": {
+                "driver": {
+                    "name": "buildifier",
+                    "informationUri": "https://github.com/bazel-contrib/buildtools/tree/main/buildifier",
+                    "rules": []
+                }
+            },
+            "columnKind": "unicodeCodePoints",
+            "results": []
+        }
+    ]
+}
+EOF
+
+cat > golden/sarif_report_stdin_golden <<'EOF'
+{
+    "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
+    "version": "2.1.0",
+    "runs": [
+        {
+            "tool": {
+                "driver": {
+                    "name": "buildifier",
+                    "informationUri": "https://github.com/bazel-contrib/buildtools/tree/main/buildifier",
+                    "rules": [
+                        {
+                            "id": "format",
+                            "shortDescription": {
+                                "text": "The file is not formatted according to buildifier's rules."
+                            }
+                        }
+                    ]
+                }
+            },
+            "columnKind": "unicodeCodePoints",
+            "results": [
+                {
+                    "ruleId": "format",
+                    "ruleIndex": 0,
+                    "level": "warning",
+                    "message": {
+                        "text": "The file needs to be reformatted."
+                    },
+                    "locations": [
+                        {
+                            "physicalLocation": {
+                                "artifactLocation": {
+                                    "uri": "%3Cstdin%3E"
+                                }
+                            }
+                        }
+                    ]
+                }
+            ]
+        }
+    ]
+}
+EOF
+
+cat > golden/sarif_report_invalid_file_golden <<'EOF'
+{
+    "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
+    "version": "2.1.0",
+    "runs": [
+        {
+            "tool": {
+                "driver": {
+                    "name": "buildifier",
+                    "informationUri": "https://github.com/bazel-contrib/buildtools/tree/main/buildifier",
+                    "rules": [
+                        {
+                            "id": "parse-error",
+                            "shortDescription": {
+                                "text": "The file is not a valid Starlark file."
+                            }
+                        }
+                    ]
+                }
+            },
+            "columnKind": "unicodeCodePoints",
+            "results": [
+                {
+                    "ruleId": "parse-error",
+                    "ruleIndex": 0,
+                    "level": "error",
+                    "message": {
+                        "text": "The file could not be parsed."
+                    },
+                    "locations": [
+                        {
+                            "physicalLocation": {
+                                "artifactLocation": {
+                                    "uri": "foo.bar"
+                                }
+                            }
+                        }
+                    ]
+                }
+            ]
+        }
+    ]
+}
+EOF
+
 cd test_dir/json
 
 $buildifier --mode=check --format=json --lint=warn --warnings=-module-docstring -v to_fix.bzl to_fix_2.bzl to_fix_3.bzl to_fix_4.bzl > json_report
@@ -672,6 +925,18 @@
 $buildifier --mode=check --format=json --lint=warn --warnings=-module-docstring -v to_fix_4.bzl foo.bar > json_report
 diff -u json_report ../../golden/json_report_invalid_file_golden || die "$1: wrong console output for --mode=check --format=json --lint=warn with an invalid file"
 
+$buildifier --mode=check --format=sarif --lint=warn --warnings=-module-docstring -v to_fix.bzl to_fix_2.bzl to_fix_3.bzl to_fix_4.bzl > sarif_report
+diff -u sarif_report ../../golden/sarif_report_golden || die "$1: wrong console output for --mode=check --format=sarif --lint=warn with many files"
+
+$buildifier --mode=check --format=sarif --lint=warn --warnings=-module-docstring -v to_fix_4.bzl > sarif_report
+diff -u sarif_report ../../golden/sarif_report_small_golden || die "$1: wrong console output for --mode=check --format=sarif --lint=warn with a single file"
+
+$buildifier --mode=check --format=sarif --lint=warn --warnings=-module-docstring -v < to_fix_2.bzl > sarif_report
+diff -u sarif_report ../../golden/sarif_report_stdin_golden || die "$1: wrong console output for --mode=check --format=sarif --lint=warn with stdin"
+
+$buildifier --mode=check --format=sarif --lint=warn --warnings=-module-docstring -v to_fix_4.bzl foo.bar > sarif_report
+diff -u sarif_report ../../golden/sarif_report_invalid_file_golden || die "$1: wrong console output for --mode=check --format=sarif --lint=warn with an invalid file"
+
 cd ../..
 
 # Test the multifile functionality
diff --git a/buildifier/utils/BUILD.bazel b/buildifier/utils/BUILD.bazel
index ec6aab9..c406ec6 100644
--- a/buildifier/utils/BUILD.bazel
+++ b/buildifier/utils/BUILD.bazel
@@ -4,6 +4,7 @@
     name = "utils",
     srcs = [
         "diagnostics.go",
+        "sarif.go",
         "tempfile.go",
         "utils.go",
     ],
@@ -17,7 +18,10 @@
 
 go_test(
     name = "utils_test",
-    srcs = ["utils_test.go"],
+    srcs = [
+        "sarif_test.go",
+        "utils_test.go",
+    ],
     embed = [":utils"],
 )
 
diff --git a/buildifier/utils/diagnostics.go b/buildifier/utils/diagnostics.go
index 8de8534..412bd45 100644
--- a/buildifier/utils/diagnostics.go
+++ b/buildifier/utils/diagnostics.go
@@ -31,7 +31,7 @@
 	Files   []*FileDiagnostics `json:"files"`   // diagnostics per file
 }
 
-// Format formats a Diagnostics object either as plain text or as json
+// Format formats a Diagnostics object either as plain text, json or sarif
 func (d *Diagnostics) Format(format string, verbose bool) string {
 	switch format {
 	case "text", "":
@@ -62,6 +62,8 @@
 			result, _ = json.Marshal(*d)
 		}
 		return string(result) + "\n"
+	case "sarif":
+		return d.formatSarif(verbose)
 	}
 	return ""
 }
diff --git a/buildifier/utils/sarif.go b/buildifier/utils/sarif.go
new file mode 100644
index 0000000..d880341
--- /dev/null
+++ b/buildifier/utils/sarif.go
@@ -0,0 +1,208 @@
+/*
+Copyright 2026 Google LLC
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package utils
+
+import (
+	"encoding/json"
+	"net/url"
+	"path/filepath"
+	"strings"
+)
+
+const (
+	sarifSchema       = "https://json.schemastore.org/sarif-2.1.0.json"
+	sarifVersion      = "2.1.0"
+	sarifToolURI      = "https://github.com/bazel-contrib/buildtools/tree/main/buildifier"
+	sarifLevelWarning = "warning"
+	sarifLevelError   = "error"
+	// Columns are counted in runes, see build.Position.LineRune.
+	sarifColumnKind = "unicodeCodePoints"
+	sarifFormatRule = "format"
+	sarifParseRule  = "parse-error"
+)
+
+// Minimal subset of SARIF 2.1.0 needed to report buildifier diagnostics.
+
+type sarifLog struct {
+	Schema  string     `json:"$schema"`
+	Version string     `json:"version"`
+	Runs    []sarifRun `json:"runs"`
+}
+
+type sarifRun struct {
+	Tool       sarifTool     `json:"tool"`
+	ColumnKind string        `json:"columnKind"`
+	Results    []sarifResult `json:"results"`
+}
+
+type sarifTool struct {
+	Driver sarifDriver `json:"driver"`
+}
+
+type sarifDriver struct {
+	Name           string      `json:"name"`
+	InformationURI string      `json:"informationUri"`
+	Rules          []sarifRule `json:"rules"`
+}
+
+type sarifRule struct {
+	ID               string        `json:"id"`
+	ShortDescription *sarifMessage `json:"shortDescription,omitempty"`
+	HelpURI          string        `json:"helpUri,omitempty"`
+}
+
+type sarifResult struct {
+	RuleID    string          `json:"ruleId"`
+	RuleIndex int             `json:"ruleIndex"`
+	Level     string          `json:"level"`
+	Message   sarifMessage    `json:"message"`
+	Locations []sarifLocation `json:"locations"`
+}
+
+type sarifMessage struct {
+	Text string `json:"text"`
+}
+
+type sarifLocation struct {
+	PhysicalLocation sarifPhysicalLocation `json:"physicalLocation"`
+}
+
+type sarifPhysicalLocation struct {
+	ArtifactLocation sarifArtifactLocation `json:"artifactLocation"`
+	Region           *sarifRegion          `json:"region,omitempty"`
+}
+
+type sarifArtifactLocation struct {
+	URI string `json:"uri"`
+}
+
+type sarifRegion struct {
+	StartLine   int `json:"startLine"`
+	StartColumn int `json:"startColumn,omitempty"`
+	EndLine     int `json:"endLine,omitempty"`
+	EndColumn   int `json:"endColumn,omitempty"`
+}
+
+// sarifBuilder collects rules and results, deduplicating rules by id.
+type sarifBuilder struct {
+	rules   []sarifRule
+	indexes map[string]int
+	results []sarifResult
+}
+
+func (b *sarifBuilder) ruleIndex(id, description, helpURI string) int {
+	if i, ok := b.indexes[id]; ok {
+		return i
+	}
+	rule := sarifRule{ID: id, HelpURI: helpURI}
+	if description != "" {
+		rule.ShortDescription = &sarifMessage{Text: description}
+	}
+	b.rules = append(b.rules, rule)
+	b.indexes[id] = len(b.rules) - 1
+	return b.indexes[id]
+}
+
+func (b *sarifBuilder) add(ruleID, description, helpURI, level, message, filename string, region *sarifRegion) {
+	b.results = append(b.results, sarifResult{
+		RuleID:    ruleID,
+		RuleIndex: b.ruleIndex(ruleID, description, helpURI),
+		Level:     level,
+		Message:   sarifMessage{Text: escapeSarifBraces(message)},
+		Locations: []sarifLocation{{
+			PhysicalLocation: sarifPhysicalLocation{
+				ArtifactLocation: sarifArtifactLocation{URI: sarifURI(filename)},
+				Region:           region,
+			},
+		}},
+	})
+}
+
+// escapeSarifBraces escapes the placeholder delimiters in message strings (SARIF 3.11.5).
+var escapeSarifBraces = strings.NewReplacer("{", "{{", "}", "}}").Replace
+
+// sarifURI converts a file name into a URI reference as required by SARIF 3.4.3:
+// special characters are percent-encoded, absolute paths become file URIs and
+// relative paths are relative-path references.
+func sarifURI(filename string) string {
+	path := filepath.ToSlash(filename)
+	if filepath.IsAbs(filename) {
+		if !strings.HasPrefix(path, "/") {
+			path = "/" + path // Windows drive letter, e.g. C:/dir
+		}
+		return (&url.URL{Scheme: "file", Path: path}).String()
+	}
+	return (&url.URL{Path: path}).String()
+}
+
+// formatSarif renders the diagnostics as a SARIF 2.1.0 log.
+func (d *Diagnostics) formatSarif(verbose bool) string {
+	b := &sarifBuilder{indexes: map[string]int{}}
+
+	for _, f := range d.Files {
+		for _, w := range f.Warnings {
+			// Without a start line the result is reported at file level.
+			var region *sarifRegion
+			if w.Start.Line > 0 {
+				region = &sarifRegion{
+					StartLine:   w.Start.Line,
+					StartColumn: w.Start.Column,
+					EndLine:     w.End.Line,
+					EndColumn:   w.End.Column,
+				}
+			}
+			b.add(w.Category, "", w.URL, sarifLevelWarning, w.Message, f.Filename, region)
+		}
+		switch {
+		case !f.Valid:
+			b.add(sarifParseRule, "The file is not a valid Starlark file.", "", sarifLevelError,
+				"The file could not be parsed.", f.Filename, nil)
+		case !f.Formatted:
+			b.add(sarifFormatRule, "The file is not formatted according to buildifier's rules.", "", sarifLevelWarning,
+				"The file needs to be reformatted.", f.Filename, nil)
+		}
+	}
+
+	if b.rules == nil {
+		b.rules = []sarifRule{}
+	}
+	if b.results == nil {
+		b.results = []sarifResult{}
+	}
+
+	log := sarifLog{
+		Schema:  sarifSchema,
+		Version: sarifVersion,
+		Runs: []sarifRun{{
+			Tool: sarifTool{Driver: sarifDriver{
+				Name:           "buildifier",
+				InformationURI: sarifToolURI,
+				Rules:          b.rules,
+			}},
+			ColumnKind: sarifColumnKind,
+			Results:    b.results,
+		}},
+	}
+
+	var out []byte
+	if verbose {
+		out, _ = json.MarshalIndent(log, "", "    ")
+	} else {
+		out, _ = json.Marshal(log)
+	}
+	return string(out) + "\n"
+}
diff --git a/buildifier/utils/sarif_test.go b/buildifier/utils/sarif_test.go
new file mode 100644
index 0000000..16d2bc0
--- /dev/null
+++ b/buildifier/utils/sarif_test.go
@@ -0,0 +1,115 @@
+/*
+Copyright 2026 Google LLC
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package utils
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+
+	"github.com/bazel-contrib/buildtools/v10/build"
+	"github.com/bazel-contrib/buildtools/v10/warn"
+)
+
+func TestFormatSarif(t *testing.T) {
+	finding := &warn.Finding{
+		Start:    build.Position{Line: 3, LineRune: 5},
+		End:      build.Position{Line: 3, LineRune: 10},
+		Category: "integer-division",
+		Message:  "msg",
+		URL:      "https://example.com#integer-division",
+	}
+	good := NewFileDiagnostics("a/good.bzl", nil)
+	warned := NewFileDiagnostics("a/warn.bzl", []*warn.Finding{finding, finding})
+	unformatted := NewFileDiagnostics("a/fmt.bzl", nil)
+	unformatted.Formatted = false
+	invalid := InvalidFileDiagnostics("a/bad.bzl")
+
+	out := NewDiagnostics(good, warned, unformatted, invalid).Format("sarif", false)
+	var log sarifLog
+	if err := json.Unmarshal([]byte(out), &log); err != nil {
+		t.Fatalf("invalid json: %v", err)
+	}
+	if log.Version != "2.1.0" || len(log.Runs) != 1 {
+		t.Fatalf("unexpected log header: %+v", log)
+	}
+	run := log.Runs[0]
+	if len(run.Results) != 4 {
+		t.Fatalf("got %d results, want 4", len(run.Results))
+	}
+	if len(run.Tool.Driver.Rules) != 3 {
+		t.Errorf("got %d rules, want 3 (deduplicated)", len(run.Tool.Driver.Rules))
+	}
+	r := run.Results[0]
+	reg := r.Locations[0].PhysicalLocation.Region
+	if r.RuleID != "integer-division" || r.Level != "warning" || reg == nil ||
+		reg.StartLine != 3 || reg.StartColumn != 5 || reg.EndColumn != 10 {
+		t.Errorf("unexpected warning result: %+v", r)
+	}
+	if run.Results[2].RuleID != "format" || run.Results[2].Locations[0].PhysicalLocation.Region != nil {
+		t.Errorf("unexpected format result: %+v", run.Results[2])
+	}
+	if run.Results[3].RuleID != "parse-error" || run.Results[3].Level != "error" {
+		t.Errorf("unexpected parse result: %+v", run.Results[3])
+	}
+	for i, res := range run.Results {
+		if run.Tool.Driver.Rules[res.RuleIndex].ID != res.RuleID {
+			t.Errorf("result %d has inconsistent ruleIndex", i)
+		}
+	}
+}
+
+func TestFormatSarifEmpty(t *testing.T) {
+	out := NewDiagnostics(NewFileDiagnostics("a.bzl", nil)).Format("sarif", true)
+	if !strings.Contains(out, `"results": []`) || !strings.Contains(out, `"rules": []`) {
+		t.Errorf("expected empty arrays, got:\n%s", out)
+	}
+}
+
+func TestSarifURI(t *testing.T) {
+	for in, want := range map[string]string{
+		"a/b.bzl":    "a/b.bzl",
+		"a b/c.bzl":  "a%20b/c.bzl",
+		"<stdin>":    "%3Cstdin%3E",
+		"/tmp/x.bzl": "file:///tmp/x.bzl",
+		"a:b/c.bzl":  "./a:b/c.bzl",
+	} {
+		if got := sarifURI(in); got != want {
+			t.Errorf("sarifURI(%q) = %q, want %q", in, got, want)
+		}
+	}
+}
+
+func TestFormatSarifEscapesBracesAndSetsColumnKind(t *testing.T) {
+	finding := &warn.Finding{
+		Start:    build.Position{Line: 1, LineRune: 1},
+		End:      build.Position{Line: 1, LineRune: 2},
+		Category: "c",
+		Message:  `use {"a": 1}`,
+	}
+	out := NewDiagnostics(NewFileDiagnostics("a.bzl", []*warn.Finding{finding})).Format("sarif", false)
+	var log sarifLog
+	if err := json.Unmarshal([]byte(out), &log); err != nil {
+		t.Fatal(err)
+	}
+	if got, want := log.Runs[0].Results[0].Message.Text, `use {{"a": 1}}`; got != want {
+		t.Errorf("message = %q, want %q", got, want)
+	}
+	if log.Runs[0].ColumnKind != "unicodeCodePoints" {
+		t.Errorf("columnKind = %q", log.Runs[0].ColumnKind)
+	}
+}