1 package lintcmd
2 3 // Notes on GitHub-specific restrictions:
4 //
5 // Result.Message needs to either have ID or Text set. Markdown
6 // gets ignored. Text isn't treated verbatim however: Markdown
7 // formatting gets stripped, except for links.
8 //
9 // GitHub does not display RelatedLocations. The only way to make
10 // use of them is to link to them (via their ID) in the
11 // Result.Message. And even then, it will only show the referred
12 // line of code, not the message. We can duplicate the messages in
13 // the Result.Message, but we can't even indent them, because
14 // leading whitespace gets stripped.
15 //
16 // GitHub does use the Markdown version of rule help, but it
17 // renders it the way it renders comments on issues – that is, it
18 // turns line breaks into hard line breaks, even though it
19 // shouldn't.
20 //
21 // GitHub doesn't make use of the tool's URI or version, nor of
22 // the help URIs of rules.
23 //
24 // There does not seem to be a way of using SARIF for "normal" CI,
25 // without results showing up as code scanning alerts. Also, a
26 // SARIF file containing only warnings, no errors, will not fail
27 // CI by default, but this is configurable.
28 // GitHub does display some parts of SARIF results in PRs, but
29 // most of the useful parts of SARIF, such as help text of rules,
30 // is only accessible via the code scanning alerts, which are only
31 // accessible by users with write permissions.
32 //
33 // Result.Suppressions is being ignored.
34 //
35 //
36 // Notes on other tools
37 //
38 // VS Code Sarif viewer
39 //
40 // The Sarif viewer in VS Code displays the full message in the
41 // tabular view, removing newlines. That makes our multi-line
42 // messages (which we use as a workaround for missing related
43 // information) very ugly.
44 //
45 // Much like GitHub, the Sarif viewer does not make related
46 // information visible unless we explicitly refer to it in the
47 // message.
48 //
49 // Suggested fixes are not exposed in any way.
50 //
51 // It only shows the shortDescription or fullDescription of a
52 // rule, not its help. We can't put the help in fullDescription,
53 // because the fullDescription isn't meant to be that long. For
54 // example, GitHub displays it in a single line, under the
55 // shortDescription.
56 //
57 // VS Code can filter based on Result.Suppressions, but it doesn't
58 // display our suppression message. Also, by default, suppressed
59 // results get shown, and the column indicating that a result is
60 // suppressed is hidden, which makes for a confusing experience.
61 //
62 // When a rule has only an ID, no name, VS Code displays a
63 // prominent dash in place of the name. When the name and ID are
64 // identical, it prints both. However, we can't make them
65 // identical, as SARIF requires that either the ID and name are
66 // different, or that the name is omitted.
67 68 // FIXME(dh): we're currently reporting column information using UTF-8
69 // byte offsets, not using Unicode code points or UTF-16, which are
70 // the only two ways allowed by SARIF.
71 72 // TODO(dh) set properties.tags – we can use different tags for the
73 // staticcheck, simple, stylecheck and unused checks, so users can
74 // filter their results
75 76 import (
77 "encoding/json"
78 "fmt"
79 "net/url"
80 "os"
81 "path/filepath"
82 "regexp"
83 "strings"
84 85 "honnef.co/go/tools/analysis/lint"
86 "honnef.co/go/tools/sarif"
87 )
88 89 type sarifFormatter struct {
90 driverName string
91 driverVersion string
92 driverWebsite string
93 }
94 95 func sarifLevel(severity lint.Severity) string {
96 switch severity {
97 case lint.SeverityNone:
98 // no configured severity, default to warning
99 return "warning"
100 case lint.SeverityError:
101 return "error"
102 case lint.SeverityDeprecated:
103 return "warning"
104 case lint.SeverityWarning:
105 return "warning"
106 case lint.SeverityInfo:
107 return "note"
108 case lint.SeverityHint:
109 return "note"
110 default:
111 // unreachable
112 return "none"
113 }
114 }
115 116 func encodePath(path string) string {
117 return (&url.URL{Path: path}).EscapedPath()
118 }
119 120 func sarifURI(path string) string {
121 u := url.URL{
122 Scheme: "file",
123 Path: path,
124 }
125 return u.String()
126 }
127 128 func sarifArtifactLocation(name string) sarif.ArtifactLocation {
129 // Ideally we use relative paths so that GitHub can resolve them
130 name = shortPath(name)
131 if filepath.IsAbs(name) {
132 return sarif.ArtifactLocation{
133 URI: sarifURI(name),
134 }
135 } else {
136 return sarif.ArtifactLocation{
137 URI: encodePath(name),
138 URIBaseID: "%SRCROOT%", // This is specific to GitHub,
139 }
140 }
141 }
142 143 func sarifFormatText(s string) string {
144 // GitHub doesn't ignore line breaks, even though it should, so we remove them.
145 146 var out strings.Builder
147 lines := strings.Split(s, "\n")
148 for i, line := range lines[:len(lines)-1] {
149 out.WriteString(line)
150 if line == "" {
151 out.WriteString("\n")
152 } else {
153 nextLine := lines[i+1]
154 if nextLine == "" || strings.HasPrefix(line, "> ") || strings.HasPrefix(line, " ") {
155 out.WriteString("\n")
156 } else {
157 out.WriteString(" ")
158 }
159 }
160 }
161 out.WriteString(lines[len(lines)-1])
162 return convertCodeBlocks(out.String())
163 }
164 165 func moreCodeFollows(lines []string) bool {
166 for _, line := range lines {
167 if line == "" {
168 continue
169 }
170 if strings.HasPrefix(line, " ") {
171 return true
172 } else {
173 return false
174 }
175 }
176 return false
177 }
178 179 var alpha = regexp.MustCompile(`^[a-zA-Z ]+$`)
180 181 func convertCodeBlocks(text string) string {
182 var buf strings.Builder
183 lines := strings.Split(text, "\n")
184 185 inCode := false
186 empties := 0
187 for i, line := range lines {
188 if inCode {
189 if !moreCodeFollows(lines[i:]) {
190 if inCode {
191 fmt.Fprintln(&buf, "```")
192 inCode = false
193 }
194 }
195 }
196 197 prevEmpties := empties
198 if line == "" && !inCode {
199 empties++
200 } else {
201 empties = 0
202 }
203 204 if line == "" {
205 fmt.Fprintln(&buf)
206 continue
207 }
208 209 if strings.HasPrefix(line, " ") {
210 line = line[4:]
211 if !inCode {
212 fmt.Fprintln(&buf, "```go")
213 inCode = true
214 }
215 }
216 217 onlyAlpha := alpha.MatchString(line)
218 out := line
219 if !inCode && prevEmpties >= 2 && onlyAlpha {
220 fmt.Fprintf(&buf, "## %s\n", out)
221 } else {
222 fmt.Fprint(&buf, out)
223 fmt.Fprintln(&buf)
224 }
225 }
226 if inCode {
227 fmt.Fprintln(&buf, "```")
228 }
229 230 return buf.String()
231 }
232 233 func (o *sarifFormatter) Format(checks []*lint.Analyzer, diagnostics []diagnostic) {
234 // TODO(dh): some diagnostics shouldn't be reported as results. For example, when the user specifies a package on the command line that doesn't exist.
235 236 cwd, _ := os.Getwd()
237 run := sarif.Run{
238 Tool: sarif.Tool{
239 Driver: sarif.ToolComponent{
240 Name: o.driverName,
241 Version: o.driverVersion,
242 InformationURI: o.driverWebsite,
243 },
244 },
245 Invocations: []sarif.Invocation{{
246 Arguments: os.Args[1:],
247 WorkingDirectory: sarif.ArtifactLocation{
248 URI: sarifURI(cwd),
249 },
250 ExecutionSuccessful: true,
251 }},
252 }
253 for _, c := range checks {
254 doc := c.Doc.Compile()
255 run.Tool.Driver.Rules = append(run.Tool.Driver.Rules,
256 sarif.ReportingDescriptor{
257 // We don't set Name, as Name and ID mustn't be identical.
258 ID: c.Analyzer.Name,
259 ShortDescription: sarif.Message{
260 Text: doc.Title,
261 Markdown: doc.TitleMarkdown,
262 },
263 HelpURI: "https://staticcheck.dev/docs/checks#" + c.Analyzer.Name,
264 // We use our markdown as the plain text version, too. We
265 // use very little markdown, primarily quotations,
266 // indented code blocks and backticks. All of these are
267 // fine as plain text, too.
268 Help: sarif.Message{
269 Text: sarifFormatText(doc.Format(false)),
270 Markdown: sarifFormatText(doc.FormatMarkdown(false)),
271 },
272 DefaultConfiguration: sarif.ReportingConfiguration{
273 // TODO(dh): we could figure out which checks were disabled globally
274 Enabled: true,
275 Level: sarifLevel(doc.Severity),
276 },
277 })
278 }
279 280 for _, p := range diagnostics {
281 r := sarif.Result{
282 RuleID: p.Category,
283 Kind: sarif.Fail,
284 Message: sarif.Message{
285 Text: p.Message,
286 },
287 }
288 r.Locations = []sarif.Location{{
289 PhysicalLocation: sarif.PhysicalLocation{
290 ArtifactLocation: sarifArtifactLocation(p.Position.Filename),
291 Region: sarif.Region{
292 StartLine: p.Position.Line,
293 StartColumn: p.Position.Column,
294 EndLine: p.End.Line,
295 EndColumn: p.End.Column,
296 },
297 },
298 }}
299 for _, fix := range p.SuggestedFixes {
300 sfix := sarif.Fix{
301 Description: sarif.Message{
302 Text: fix.Message,
303 },
304 }
305 // file name -> replacements
306 changes := map[string][]sarif.Replacement{}
307 for _, edit := range fix.TextEdits {
308 changes[edit.Position.Filename] = append(changes[edit.Position.Filename], sarif.Replacement{
309 DeletedRegion: sarif.Region{
310 StartLine: edit.Position.Line,
311 StartColumn: edit.Position.Column,
312 EndLine: edit.End.Line,
313 EndColumn: edit.End.Column,
314 },
315 InsertedContent: sarif.ArtifactContent{
316 Text: string(edit.NewText),
317 },
318 })
319 }
320 for path, replacements := range changes {
321 sfix.ArtifactChanges = append(sfix.ArtifactChanges, sarif.ArtifactChange{
322 ArtifactLocation: sarifArtifactLocation(path),
323 Replacements: replacements,
324 })
325 }
326 r.Fixes = append(r.Fixes, sfix)
327 }
328 for i, related := range p.Related {
329 r.Message.Text += fmt.Sprintf("\n\t[%s](%d)", related.Message, i+1)
330 331 r.RelatedLocations = append(r.RelatedLocations,
332 sarif.Location{
333 ID: i + 1,
334 Message: &sarif.Message{
335 Text: related.Message,
336 },
337 PhysicalLocation: sarif.PhysicalLocation{
338 ArtifactLocation: sarifArtifactLocation(related.Position.Filename),
339 Region: sarif.Region{
340 StartLine: related.Position.Line,
341 StartColumn: related.Position.Column,
342 EndLine: related.End.Line,
343 EndColumn: related.End.Column,
344 },
345 },
346 })
347 }
348 349 if p.Severity == severityIgnored {
350 // Note that GitHub does not support suppressions, which is why Staticcheck still requires the -show-ignored flag to be set for us to emit ignored diagnostics.
351 352 r.Suppressions = []sarif.Suppression{{
353 Kind: "inSource",
354 // TODO(dh): populate the Justification field
355 }}
356 } else {
357 // We want an empty slice, not nil. SARIF differentiates
358 // between the two. An empty slice means that the diagnostic
359 // wasn't suppressed, while nil means that we don't have the
360 // information available.
361 r.Suppressions = []sarif.Suppression{}
362 }
363 run.Results = append(run.Results, r)
364 }
365 366 json.NewEncoder(os.Stdout).Encode(sarif.Log{
367 Version: sarif.Version,
368 Schema: sarif.Schema,
369 Runs: []sarif.Run{run},
370 })
371 }
372