Module: SimpleCov::CLI::Annotations

Extended by:
Annotations
Included in:
Annotations
Defined in:
lib/simplecov/cli/annotations.rb

Overview

The --annotate KIND output shared by uncovered and patch: each command reduces its answer to diagnostics (a path, a contiguous missed line range, and the criterion that missed), and one emitter per CI host renders them in that host's native inline-annotation channel, so a gap surfaces on the diff with no upload step and no extra gem.

Constant Summary collapse

KINDS =
%w[github gitlab rdjson azure teamcity buildkite].freeze
MESSAGES =
{
  line: "Not covered by tests",
  branch: "Branch not covered by tests",
  method: "Method not covered by tests"
}.freeze
DESCRIPTIONS =
{
  line: "Lines the tests never executed",
  branch: "Branches the tests never took",
  method: "Methods the tests never called"
}.freeze
SOURCE =
{"name" => "simplecov", "url" => "https://github.com/simplecov-ruby/simplecov"}.freeze
TEAMCITY_ESCAPES =

TeamCity's service message grammar and Azure's logging command grammar each reserve a few characters in attribute values.

{"|" => "||", "'" => "|'", "[" => "|[", "]" => "|]", "\n" => "|n", "\r" => "|r"}.freeze
AZURE_ESCAPES =
{"%" => "%AZP25", ";" => "%3B", "]" => "%5D", "\n" => "%0A", "\r" => "%0D"}.freeze

Instance Method Summary collapse

Instance Method Details

#azure(stdout, diagnostics) ⇒ Object

Azure Pipelines logging commands carry one line number, so a range is spelled out in the message.



116
117
118
119
120
121
# File 'lib/simplecov/cli/annotations.rb', line 116

def azure(stdout, diagnostics)
  diagnostics.each do |diagnostic|
    stdout.puts("##vso[task.logissue type=warning;sourcepath=#{escape(diagnostic.fetch(:path), AZURE_ESCAPES)};" \
                "linenumber=#{diagnostic.fetch(:first)}]#{described(diagnostic)}")
  end
end

#buildkite(stdout, diagnostics) ⇒ Object

Buildkite has no per-line channel; its annotations are Markdown, so this is the body for buildkite-agent annotate.



144
145
146
147
148
149
# File 'lib/simplecov/cli/annotations.rb', line 144

def buildkite(stdout, diagnostics)
  sections = diagnostics.group_by { |diagnostic| diagnostic.fetch(:message) }.map do |message, group|
    (["#### #{message}"] + group.map { |diagnostic| "- `#{diagnostic.fetch(:path)}:#{range(diagnostic)}`" }).join("\n")
  end
  stdout.puts(sections.join("\n\n")) unless sections.empty?
end

#described(diagnostic) ⇒ Object



151
152
153
154
155
# File 'lib/simplecov/cli/annotations.rb', line 151

def described(diagnostic)
  return diagnostic.fetch(:message) if diagnostic.fetch(:first).equal?(diagnostic.fetch(:last))

  "#{diagnostic.fetch(:message)} (lines #{range(diagnostic)})"
end

#diagnostics(path, missed, criterion) ⇒ Object



50
51
52
53
54
# File 'lib/simplecov/cli/annotations.rb', line 50

def diagnostics(path, missed, criterion)
  missed.slice_when { |previous, current| current > previous + 1 }.map do |run|
    {path: path, first: run.first, last: run.last, criterion: criterion, message: MESSAGES.fetch(criterion)}
  end
end

#emit(stdout, kind, diagnostics) ⇒ Object



56
57
58
59
60
61
62
63
64
65
# File 'lib/simplecov/cli/annotations.rb', line 56

def emit(stdout, kind, diagnostics)
  case kind
  when "github" then github(stdout, diagnostics)
  when "gitlab" then stdout.puts(JSON.pretty_generate(gitlab(diagnostics)))
  when "rdjson" then stdout.puts(JSON.pretty_generate(rdjson(diagnostics)))
  when "azure" then azure(stdout, diagnostics)
  when "teamcity" then teamcity(stdout, diagnostics)
  else buildkite(stdout, diagnostics)
  end
end

#escape(text, escapes) ⇒ Object



161
162
163
# File 'lib/simplecov/cli/annotations.rb', line 161

def escape(text, escapes)
  text.gsub(Regexp.union(escapes.keys), escapes)
end

#expected_kindsObject



45
46
47
48
# File 'lib/simplecov/cli/annotations.rb', line 45

def expected_kinds
  *rest, last = KINDS
  "#{rest.join(", ")}, or #{last}"
end

#fingerprint(diagnostic) ⇒ Object



91
92
93
94
# File 'lib/simplecov/cli/annotations.rb', line 91

def fingerprint(diagnostic)
  Digest::SHA256.hexdigest("#{diagnostic.fetch(:path)}:#{diagnostic.fetch(:first)}-#{diagnostic.fetch(:last)}:" \
                           "#{diagnostic.fetch(:criterion)}")
end

#github(stdout, diagnostics) ⇒ Object



67
68
69
70
71
72
# File 'lib/simplecov/cli/annotations.rb', line 67

def github(stdout, diagnostics)
  diagnostics.each do |diagnostic|
    stdout.puts("::warning file=#{diagnostic.fetch(:path)},line=#{diagnostic.fetch(:first)}," \
                "endLine=#{diagnostic.fetch(:last)}::#{diagnostic.fetch(:message)}")
  end
end

#gitlab(diagnostics) ⇒ Object

GitLab's Code Quality report, a subset of the Code Climate spec, read from a codequality artifact for merge request annotations.



76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/simplecov/cli/annotations.rb', line 76

def gitlab(diagnostics)
  diagnostics.map do |diagnostic|
    {
      "description" => diagnostic.fetch(:message),
      "check_name" => "coverage",
      "fingerprint" => fingerprint(diagnostic),
      "severity" => "minor",
      "location" => {
        "path" => diagnostic.fetch(:path),
        "lines" => {"begin" => diagnostic.fetch(:first), "end" => diagnostic.fetch(:last)}
      }
    }
  end
end

#issue(opts) ⇒ Object



37
38
39
40
41
42
43
# File 'lib/simplecov/cli/annotations.rb', line 37

def issue(opts)
  kind = opts.fetch(:annotate)
  return nil unless kind
  return "unknown --annotate #{kind.inspect} (expected #{expected_kinds})" unless KINDS.include?(kind)

  "cannot combine --annotate with --json" if opts.fetch(:json)
end

#range(diagnostic) ⇒ Object



157
158
159
# File 'lib/simplecov/cli/annotations.rb', line 157

def range(diagnostic)
  [diagnostic.fetch(:first), diagnostic.fetch(:last)].uniq.join("-")
end

#rdjson(diagnostics) ⇒ Object

reviewdog's Diagnostic Format, which reviewdog posts to whichever host it runs under.



98
99
100
# File 'lib/simplecov/cli/annotations.rb', line 98

def rdjson(diagnostics)
  {"source" => SOURCE, "severity" => "WARNING", "diagnostics" => diagnostics.map { |d| rdjson_diagnostic(d) }}
end

#rdjson_diagnostic(diagnostic) ⇒ Object



102
103
104
105
106
107
108
109
110
111
112
# File 'lib/simplecov/cli/annotations.rb', line 102

def rdjson_diagnostic(diagnostic)
  {
    "message" => diagnostic.fetch(:message),
    "location" => {
      "path" => diagnostic.fetch(:path),
      "range" => {"start" => {"line" => diagnostic.fetch(:first)}, "end" => {"line" => diagnostic.fetch(:last)}}
    },
    "severity" => "WARNING",
    "code" => {"value" => diagnostic.fetch(:criterion).to_s}
  }
end

#service_message(name, attributes) ⇒ Object



137
138
139
140
# File 'lib/simplecov/cli/annotations.rb', line 137

def service_message(name, attributes)
  body = attributes.map { |key, value| "#{key}='#{escape(value.to_s, TEAMCITY_ESCAPES)}'" }.join(" ")
  "##teamcity[#{name} #{body}]"
end

#teamcity(stdout, diagnostics) ⇒ Object

TeamCity's code inspection service messages: each criterion is one inspection type, declared once before its first inspection.



125
126
127
128
129
130
131
132
133
134
135
# File 'lib/simplecov/cli/annotations.rb', line 125

def teamcity(stdout, diagnostics)
  diagnostics.group_by { |diagnostic| diagnostic.fetch(:criterion) }.each do |criterion, group|
    id = "simplecov.#{criterion}"
    stdout.puts(service_message("inspectionType", id: id, name: MESSAGES.fetch(criterion),
      description: DESCRIPTIONS.fetch(criterion), category: "Code coverage"))
    group.each do |diagnostic|
      stdout.puts(service_message("inspection", typeId: id, message: described(diagnostic),
        file: diagnostic.fetch(:path), line: diagnostic.fetch(:first), SEVERITY: "WARNING"))
    end
  end
end