Module: SimpleCov::CLI::Patch

Extended by:
CommandHelpers, Patch
Included in:
Patch
Defined in:
lib/simplecov/cli/patch.rb,
lib/simplecov/cli/patch/output.rb,
lib/simplecov/cli/patch/changed_lines.rb

Overview

simplecov patch [--base REF]: line coverage over only the lines a change touched. Where diff asks "did the number move" and needs a baseline artifact, patch asks the question a reviewer actually asks: is the code in this change tested? A project sitting at 40% cannot move its global number in one pull request, but it can insist that every line it adds is covered.

Only files the report already carries are scored: a changed file SimpleCov never tracked is out of scope, and a line LinesClassifier considers never relevant stays out of the denominator the same way it stays out of the file total.

Defined Under Namespace

Modules: ChangedLines, Output

Constant Summary

Constants included from CommandHelpers

CommandHelpers::STATS_ROW_FORMAT

Instance Method Summary collapse

Methods included from CommandHelpers

build_parser, command_name, common_options, error, error_nil, on_help, one?, parse_common, quiet_option, recorded_contexts, stats_row

Instance Method Details

#build_row(path, payload, changed) ⇒ Object



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

def build_row(path, payload, changed)
  {
    file: path,
    line: line_stats(payload["lines"], changed),
    branch: entry_stats(payload["branches"], changed),
    method: entry_stats(payload["methods"], changed)
  }
end

#changed_for(lines, payload) ⇒ Object

An untracked file appears in no diff, so :all stands in for its line numbers: every line the report knows is this change's work.



95
96
97
98
99
100
# File 'lib/simplecov/cli/patch.rb', line 95

def changed_for(lines, payload)
  return lines.uniq unless lines.equal?(:all)

  hits = payload["lines"]
  hits.instance_of?(Array) ? (1..hits.length).to_a : []
end

#compute_rows(coverage, diffed, stderr) ⇒ Object

Diff paths are exact root-relative names, so they resolve exactly against the report. The suffix fallback CoverageFile.lookup offers interactive commands could only ever bind a changed file the report doesn't carry to some other file's hits and score the wrong entry.



80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/simplecov/cli/patch.rb', line 80

def compute_rows(coverage, diffed, stderr)
  index = CoverageFile.exact_index(coverage)
  diffed.fetch(:changes).filter_map do |path, lines|
    payload = index[File.expand_path(path, diffed.fetch(:root))] || index[path]
    next unless payload.instance_of?(Hash) # file the report doesn't carry -> out of scope

    changed = changed_for(lines, payload)
    warn_stale(path, payload, changed, stderr)
    row = build_row(path, payload, changed)
    row if scored?(row) # nothing coverable changed in this file
  end
end

#each_touched(entries, changed) ⇒ Object

"ignored" (nocov) entries and entries off the change are skipped.



159
160
161
162
163
164
165
166
167
# File 'lib/simplecov/cli/patch.rb', line 159

def each_touched(entries, changed)
  entries.each do |entry|
    next unless entry.instance_of?(Hash)

    line = entry["report_line"] || entry["start_line"]
    hits = entry["coverage"]
    yield line, hits if changed.include?(line) && hits.instance_of?(Integer)
  end
end

#entry_stats(entries, changed) ⇒ Object

Branches and methods share the report shape (a reported line and an integer hit count), so one scorer serves both. nil when the report carries no data for the criterion, so the output and gate skip it rather than reporting a hollow 0/0. A miss is recorded at the reported line so the note points where the source is.



147
148
149
150
151
152
153
154
155
156
# File 'lib/simplecov/cli/patch.rb', line 147

def entry_stats(entries, changed)
  return nil unless entries.instance_of?(Array)

  covered = 0
  missing = [] #: Array[Integer]
  each_touched(entries, changed) do |line, hits|
    hits.positive? ? (covered += 1) : (missing << line)
  end
  {covered: covered, relevant: covered + missing.size, missing: missing.uniq.sort}
end

#line_stats(hits, changed) ⇒ Object

A line counts only where the report gives it an Integer hit count; a never-relevant line (nil) or a :nocov: line ("ignored") stays out of the denominator, the same rule the file total uses.



127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/simplecov/cli/patch.rb', line 127

def line_stats(hits, changed)
  covered = 0
  missing = [] #: Array[Integer]
  return {covered: covered, relevant: 0, missing: missing} unless hits.instance_of?(Array)

  changed.each do |number|
    hit = hits.at(number - 1)
    # nil / "ignored" (nocov) / any non-Integer -> never relevant, skipped.
    next unless hit.instance_of?(Integer)

    hit.positive? ? (covered += 1) : (missing << number)
  end
  {covered: covered, relevant: covered + missing.size, missing: missing.sort}
end

#parse(args, stderr) ⇒ Object



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/simplecov/cli/patch.rb', line 44

def parse(args, stderr)
  # No `base:` default: the run fills it in from the repository when the
  # option is left out.
  opts, rest = parse_common(args, find_renames: false, minimum: nil, annotate: nil) do |parser, options|
    parser.on("--base REF") { |v| options[:base] = v }
    parser.on("--minimum N", Float) { |v| options[:minimum] = v }
    parser.on("--find-renames") { options[:find_renames] = true }
    parser.on("--annotate KIND") { |v| options[:annotate] = v }
  end
  return unless positional_ok?(rest, stderr)

  issue = Annotations.issue(opts)
  return error_nil(stderr, issue) if issue

  opts[:coverage] = CoverageFile.load_coverage(opts.fetch(:input), command: "patch", stderr: stderr) or return nil
  opts
end

#positional_ok?(rest, stderr) ⇒ Boolean

A stray positional looks exactly like a ref, so a forgotten --base (simplecov patch feature-x) would otherwise diff against the default and gate the wrong change in silence.

Returns:

  • (Boolean)


65
66
67
68
69
70
# File 'lib/simplecov/cli/patch.rb', line 65

def positional_ok?(rest, stderr)
  return true if rest.empty?

  error(stderr, "unexpected argument #{rest.first.inspect} (did you mean `--base #{rest.first}`?)")
  false
end

#run(args, stdout:, stderr:) ⇒ Object



27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# File 'lib/simplecov/cli/patch.rb', line 27

def run(args, stdout:, stderr:, **)
  opts = parse(args, stderr)
  return 1 unless opts

  # An omitted --base resolves through origin's HEAD, so master and trunk
  # repositories work bare; in CI, pass the pull request's target branch
  # explicitly.
  opts[:base] ||= Git.default_base
  diffed = ChangedLines.call(opts.fetch(:base), find_renames: opts.fetch(:find_renames), stderr: stderr)
  return 1 unless diffed

  rows = compute_rows(opts.fetch(:coverage), diffed, stderr)
  kind = opts.fetch(:annotate)
  kind ? Output.annotate(stdout, rows, kind) : Output.emit(stdout, rows, opts)
  Output.gate(rows, opts.fetch(:minimum))
end

#scored?(row) ⇒ Boolean

Returns:

  • (Boolean)


169
170
171
172
# File 'lib/simplecov/cli/patch.rb', line 169

def scored?(row)
  row.fetch(:line).fetch(:relevant).positive? ||
    Output.measured?(row.fetch(:branch)) || Output.measured?(row.fetch(:method))
end

#warn_stale(path, payload, changed, stderr) ⇒ Object

A changed line past the end of the report's lines array reads as never-relevant and silently drops out of the denominator, which is right for a fresh report and wrong for a stale one, so say which is likelier out loud instead of letting a --minimum gate pass vacuously.



106
107
108
109
110
111
112
113
# File 'lib/simplecov/cli/patch.rb', line 106

def warn_stale(path, payload, changed, stderr)
  hits = payload["lines"]
  return unless hits.instance_of?(Array)
  return if changed.empty? || changed.max <= hits.length

  stderr.puts("simplecov patch: #{path} changed beyond the #{hits.length}-line entry in the " \
              "report (is the report stale? regenerate it and rerun)")
end