Skip to content

Commit d496596

Browse files
author
merge-queue-bot
committed
Merge PR #744: feat(output): add SARIF 2.1.0 output format for mdsmith check and fix --dry-run
2 parents 626076d + 44096e6 commit d496596

10 files changed

Lines changed: 569 additions & 24 deletions

File tree

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,6 @@ footer: |
249249
| 2607082049 | 🔲 | opus | [Foreign managed-region protection for `mdsmith fix`](plan/2607082049_foreign-managed-regions.md) |
250250
| 2607082050 | 🔲 | sonnet | [APM coexistence: `mdsmith init --apm`, guide, and kind pack](plan/2607082050_apm-coexist-guide-and-kind-pack.md) |
251251
| 2607082051 | 🔲 | opus | [Schema extensions: closed frontmatter and filename agreement](plan/2607082051_apm-schema-extensions.md) |
252-
| 2607082052 | 🔲 | sonnet | [SARIF output format for `mdsmith check`](plan/2607082052_check-sarif-output.md) |
252+
| 2607082052 | | sonnet | [SARIF output format for `mdsmith check`](plan/2607082052_check-sarif-output.md) |
253253
| 2607121915 || sonnet | [Resolve the internal/linkgraph purity-contract mismatch for wikilink resolution](plan/2607121915_arch-fix-linkgraph-wikilink-purity.md) |
254254
<?/catalog?>

cmd/mdsmith/check.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ func parseCheckFlags(args []string) (checkCLIOpts, []string, bool, int) {
6060
)
6161

6262
fs.StringVarP(&configPath, "config", "c", "", "Override config file path")
63-
fs.StringVarP(&format, "format", "f", "text", "Output format: text, json")
63+
fs.StringVarP(&format, "format", "f", "text", "Output format: text, json, sarif")
6464
fs.BoolVar(&noColor, "no-color", false, "Disable ANSI colors")
6565
fs.BoolVarP(&quiet, "quiet", "q", false, "Suppress non-error output")
6666
fs.BoolVarP(&verbose, "verbose", "v", false, "Show config, files, and rules on stderr")
@@ -223,7 +223,9 @@ func reportCheckResultTo(result *engine.Result, opts checkCLIOpts, logger *vlog.
223223
bw := bufio.NewWriterSize(stderrW, stderrBufSize)
224224
printErrorsTo(bw, result.Errors)
225225

226-
if !opts.quiet && len(result.Diagnostics) > 0 {
226+
// SARIF must be emitted even with zero diagnostics so the file is valid
227+
// SARIF 2.1.0 (not an empty byte stream) when uploaded to Code Scanning.
228+
if !opts.quiet && (len(result.Diagnostics) > 0 || opts.format == "sarif") {
227229
if code := formatDiagnosticsTo(bw, result.Diagnostics, opts.format, opts.noColor); code != 0 {
228230
_ = bw.Flush()
229231
return code

cmd/mdsmith/fix.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ func parseFixFlags(args []string) (fixCLIOpts, []string, bool, int) {
155155
var bf buildFixFlags
156156

157157
fs.StringVarP(&configPath, "config", "c", "", "Override config file path")
158-
fs.StringVarP(&format, "format", "f", "text", "Output format: text, json")
158+
fs.StringVarP(&format, "format", "f", "text", "Output format: text, json, sarif")
159159
fs.BoolVar(&noColor, "no-color", false, "Disable ANSI colors")
160160
fs.BoolVarP(&quiet, "quiet", "q", false, "Suppress non-error output")
161161
fs.BoolVarP(&verbose, "verbose", "v", false, "Show config, files, and rules on stderr")
@@ -361,11 +361,20 @@ func reportFixResultTo(opts fixCLIOpts, fixResult *fixpkg.Result, logger *vlog.L
361361
_ = bw.Flush()
362362
return code
363363
}
364+
} else if opts.dryRun && opts.format == "sarif" && !opts.quiet {
365+
// SARIF dry-run: emit diagnostics in SARIF format without the
366+
// text preview — mixing prose and structured JSON would produce
367+
// invalid output.
368+
if code := formatDiagnosticsTo(bw, fixResult.Diagnostics, opts.format, opts.noColor); code != 0 {
369+
_ = bw.Flush()
370+
return code
371+
}
364372
} else {
365373
if opts.dryRun && !opts.quiet {
366374
printDryRunPreview(bw, fixResult)
367375
}
368-
if !opts.quiet && len(fixResult.Diagnostics) > 0 {
376+
// SARIF must be emitted even with zero diagnostics (same reason as check).
377+
if !opts.quiet && (len(fixResult.Diagnostics) > 0 || opts.format == "sarif") {
369378
if code := formatDiagnosticsTo(bw, fixResult.Diagnostics, opts.format, opts.noColor); code != 0 {
370379
_ = bw.Flush()
371380
return code

cmd/mdsmith/main.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,8 @@ func formatDiagnosticsTo(w io.Writer, diags []lint.Diagnostic, format string, no
641641
switch format {
642642
case "json":
643643
formatter = &output.JSONFormatter{}
644+
case "sarif":
645+
formatter = &output.SARIFFormatter{ToolVersion: version}
644646
default:
645647
formatter = &output.TextFormatter{Color: !noColor}
646648
}
@@ -691,7 +693,7 @@ func printRunStats(format string, quiet bool, stats runStats) {
691693

692694
// printRunStatsTo writes the stats line to the supplied writer.
693695
func printRunStatsTo(w io.Writer, format string, quiet bool, stats runStats) {
694-
if quiet || format == "json" {
696+
if quiet || format == "json" || format == "sarif" {
695697
return
696698
}
697699
if stats.DryRun {

cmd/mdsmith/main_unit_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,63 @@ func TestReportFixResult_DryRunJSONOutput(t *testing.T) {
568568
assert.Equal(t, "f.md", records[0]["path"])
569569
}
570570

571+
func TestReportFixResult_DryRunSARIFOutput(t *testing.T) {
572+
opts := fixCLIOpts{dryRun: true, format: "sarif"}
573+
result := &fixpkg.Result{
574+
FilesChecked: 1,
575+
WouldFix: 1,
576+
WouldFixFiles: []fixpkg.WouldFixFile{
577+
{Path: "f.md", Count: 1, Rules: []fixpkg.RuleFixCount{{RuleID: "MDS001", Count: 1}}},
578+
},
579+
Diagnostics: []lint.Diagnostic{
580+
{
581+
File: "f.md", Line: 5, RuleID: "MDS002", RuleName: "no-fix-rule",
582+
Severity: lint.Warning, Message: "unfixable",
583+
},
584+
},
585+
}
586+
var code int
587+
var stdout string
588+
stderr := captureStderr(func() {
589+
stdout = captureStdout(func() {
590+
code = reportFixResult(opts, result, &vlog.Logger{})
591+
})
592+
})
593+
assert.Equal(t, 1, code, "unfixable diagnostics → exit 1")
594+
assert.Empty(t, stdout, "SARIF must go to stderr, not stdout")
595+
596+
var doc map[string]any
597+
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(stderr)), &doc),
598+
"dry-run SARIF output must be valid JSON")
599+
assert.Equal(t, "2.1.0", doc["version"])
600+
runs := doc["runs"].([]any)
601+
results := runs[0].(map[string]any)["results"].([]any)
602+
assert.Len(t, results, 1, "only unfixable diagnostics appear in dry-run SARIF")
603+
}
604+
605+
func TestReportFixResult_DryRunSARIFQuietSuppressesOutput(t *testing.T) {
606+
opts := fixCLIOpts{dryRun: true, format: "sarif", quiet: true}
607+
result := &fixpkg.Result{
608+
WouldFix: 1,
609+
WouldFixFiles: []fixpkg.WouldFixFile{
610+
{Path: "f.md", Count: 1},
611+
},
612+
Diagnostics: []lint.Diagnostic{
613+
{File: "f.md", Line: 1, RuleID: "MDS001", RuleName: "r", Severity: lint.Error, Message: "m"},
614+
},
615+
}
616+
var code int
617+
var stdout string
618+
stderr := captureStderr(func() {
619+
stdout = captureStdout(func() {
620+
code = reportFixResult(opts, result, &vlog.Logger{})
621+
})
622+
})
623+
assert.Equal(t, 1, code)
624+
assert.Empty(t, stdout)
625+
assert.NotContains(t, stderr, "{", "--quiet must suppress dry-run SARIF on stderr too")
626+
}
627+
571628
func TestReportFixResult_DryRunJSONQuietSuppressesOutput(t *testing.T) {
572629
opts := fixCLIOpts{dryRun: true, format: "json", quiet: true}
573630
result := &fixpkg.Result{
@@ -588,6 +645,51 @@ func TestReportFixResult_DryRunJSONQuietSuppressesOutput(t *testing.T) {
588645
assert.NotContains(t, stderr, "{", "--quiet must suppress dry-run JSON on stderr too")
589646
}
590647

648+
func TestReportCheckResult_SARIFEmittedWhenNoDiagnostics(t *testing.T) {
649+
// SARIF must always be emitted so github/codeql-action/upload-sarif
650+
// receives a valid document (not an empty file) on a clean codebase.
651+
opts := checkCLIOpts{format: "sarif"}
652+
result := &engine.Result{FilesChecked: 3}
653+
var code int
654+
var stdout string
655+
stderr := captureStderr(func() {
656+
stdout = captureStdout(func() {
657+
code = reportCheckResult(result, opts, &vlog.Logger{})
658+
})
659+
})
660+
assert.Equal(t, 0, code)
661+
assert.Empty(t, stdout, "SARIF must go to stderr")
662+
663+
var doc map[string]any
664+
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(stderr)), &doc),
665+
"must emit valid SARIF even with zero diagnostics")
666+
assert.Equal(t, "2.1.0", doc["version"])
667+
runs := doc["runs"].([]any)
668+
results := runs[0].(map[string]any)["results"].([]any)
669+
assert.Empty(t, results, "zero diagnostics → empty results array")
670+
}
671+
672+
func TestReportFixResult_SARIFEmittedWhenNoDiagnostics(t *testing.T) {
673+
// Same invariant as check: fix -f sarif must produce a valid SARIF
674+
// document even when fixing resolved all issues (Diagnostics empty).
675+
opts := fixCLIOpts{format: "sarif"}
676+
result := &fixpkg.Result{FilesChecked: 2, Modified: []string{"f.md"}}
677+
var code int
678+
var stdout string
679+
stderr := captureStderr(func() {
680+
stdout = captureStdout(func() {
681+
code = reportFixResult(opts, result, &vlog.Logger{})
682+
})
683+
})
684+
assert.Equal(t, 0, code)
685+
assert.Empty(t, stdout)
686+
687+
var doc map[string]any
688+
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(stderr)), &doc),
689+
"must emit valid SARIF even after all issues are fixed")
690+
assert.Equal(t, "2.1.0", doc["version"])
691+
}
692+
591693
func TestReportFixResult_DiagnosticsReturnsCode1(t *testing.T) {
592694
opts := fixCLIOpts{format: "text"}
593695
result := &fixpkg.Result{
@@ -2080,6 +2182,16 @@ func TestReportFixResultTo_DryRunJSONWriteErrorFlushes(t *testing.T) {
20802182
assert.Equal(t, 2, code)
20812183
}
20822184

2185+
func TestReportFixResultTo_DryRunSARIFWriteErrorReturns2(t *testing.T) {
2186+
// Drive lines 369-370 in fix.go: enough diagnostics to overflow the
2187+
// 64 KiB buffer during SARIF JSON encoding so formatDiagnosticsTo
2188+
// returns non-zero and the early-return branch is taken.
2189+
opts := fixCLIOpts{dryRun: true, format: "sarif"}
2190+
result := &fixpkg.Result{FilesChecked: 1, Diagnostics: manyDiagnostics(2000)}
2191+
code := reportFixResultTo(opts, result, &vlog.Logger{}, &alwaysErrorWriter{})
2192+
assert.Equal(t, 2, code)
2193+
}
2194+
20832195
func TestReportCheckResultTo_LargeDiagWriteErrorReturns2(t *testing.T) {
20842196
// Enough diagnostics to overflow the 64 KiB stderr buffer, so the
20852197
// formatter itself observes the write failure mid-stream and the

docs/reference/cli/check.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ discovered from `.mdsmith.yml` `files:` patterns
2424
| Flag | Default | Description |
2525
| ------------------- | ------- | -------------------------------------- |
2626
| `-c`, `--config` | auto | Override config path (auto-discovers) |
27-
| `-f`, `--format` | `text` | `text` or `json` |
27+
| `-f`, `--format` | `text` | `text`, `json`, or `sarif` |
2828
| `--max-input-size` | `2MB` | Max file size (e.g. `2MB`, `0`=none) |
2929
| `--no-color` | false | Plain output |
3030
| `--follow-symlinks` | config | Follow symlinks; tri-state — see below |
@@ -57,10 +57,30 @@ the winning source for each leaf setting:
5757
```bash
5858
mdsmith check docs/ # lint a directory
5959
mdsmith check -f json docs/ # JSON output
60+
mdsmith check -f sarif docs/ # SARIF 2.1.0 output
6061
mdsmith check --explain README.md # provenance trailer
6162
echo "# Hi" | mdsmith check - # lint stdin
6263
```
6364

65+
## GitHub Code Scanning
66+
67+
`-f sarif` emits a SARIF 2.1.0 document that GitHub's
68+
Code Scanning dashboard ingests directly. Upload with
69+
[`github/codeql-action/upload-sarif`](https://github.com/github/codeql-action):
70+
71+
```yaml
72+
- name: Run mdsmith
73+
run: mdsmith check -f sarif . 2> report.sarif || true
74+
- name: Upload SARIF
75+
uses: github/codeql-action/upload-sarif@v3
76+
with:
77+
sarif_file: report.sarif
78+
category: mdsmith
79+
```
80+
81+
Each fired rule links back to its mdsmith.dev doc page via
82+
`helpUri` in the SARIF `driver.rules` array.
83+
6484
## Pre-commit
6585

6686
```yaml

docs/reference/cli/fix.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ files are discovered from `.mdsmith.yml` `files:` patterns.
2121
| Flag | Default | Description |
2222
| ------------------- | ------- | -------------------------------------- |
2323
| `-c`, `--config` | auto | Override config path (auto-discovers) |
24-
| `-f`, `--format` | `text` | `text` or `json` |
24+
| `-f`, `--format` | `text` | `text`, `json`, or `sarif` |
2525
| `--max-input-size` | `2MB` | Max file size (e.g. `2MB`, `0`=none) |
2626
| `--no-color` | false | Plain output |
2727
| `--follow-symlinks` | config | Follow symlinks; tri-state — see below |

0 commit comments

Comments
 (0)