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



109
110
111
112
113
114
115
116
# File 'lib/simplecov/cli/patch.rb', line 109

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.



89
90
91
92
93
94
# File 'lib/simplecov/cli/patch.rb', line 89

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.



74
75
76
77
78
79
80
81
82
83
84
85
# File 'lib/simplecov/cli/patch.rb', line 74

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.



153
154
155
156
157
158
159
160
161
# File 'lib/simplecov/cli/patch.rb', line 153

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.



141
142
143
144
145
146
147
148
149
150
# File 'lib/simplecov/cli/patch.rb', line 141

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.



121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/simplecov/cli/patch.rb', line 121

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



42
43
44
45
46
47
48
49
50
51
52
53
54
# File 'lib/simplecov/cli/patch.rb', line 42

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) 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 }
  end
  return unless positional_ok?(rest, stderr)

  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)


59
60
61
62
63
64
# File 'lib/simplecov/cli/patch.rb', line 59

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



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

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)
  Output.emit(stdout, rows, opts)
  Output.gate(rows, opts.fetch(:minimum))
end

#scored?(row) ⇒ Boolean

Returns:

  • (Boolean)


163
164
165
166
# File 'lib/simplecov/cli/patch.rb', line 163

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.



100
101
102
103
104
105
106
107
# File 'lib/simplecov/cli/patch.rb', line 100

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