constable-rails 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +88 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +515 -0
  5. data/exe/constable +7 -0
  6. data/lib/constable/case.rb +336 -0
  7. data/lib/constable/cli.rb +475 -0
  8. data/lib/constable/cold_case/minitest.rb +342 -0
  9. data/lib/constable/cold_case/rspec.rb +334 -0
  10. data/lib/constable/cold_case.rb +280 -0
  11. data/lib/constable/config.rb +125 -0
  12. data/lib/constable/coverage.rb +951 -0
  13. data/lib/constable/diff.rb +212 -0
  14. data/lib/constable/dsl.rb +833 -0
  15. data/lib/constable/identity.rb +121 -0
  16. data/lib/constable/importer/modernizer.rb +860 -0
  17. data/lib/constable/importer/reopener.rb +468 -0
  18. data/lib/constable/importer.rb +51 -0
  19. data/lib/constable/investigation.rb +67 -0
  20. data/lib/constable/isolation.rb +171 -0
  21. data/lib/constable/jail.rb +399 -0
  22. data/lib/constable/log_router.rb +197 -0
  23. data/lib/constable/matchers.rb +834 -0
  24. data/lib/constable/order_audit.rb +130 -0
  25. data/lib/constable/rails_support.rb +213 -0
  26. data/lib/constable/railtie.rb +36 -0
  27. data/lib/constable/registry.rb +57 -0
  28. data/lib/constable/reporter.rb +625 -0
  29. data/lib/constable/result.rb +149 -0
  30. data/lib/constable/runner.rb +697 -0
  31. data/lib/constable/selection.rb +205 -0
  32. data/lib/constable/storage/adapter.rb +91 -0
  33. data/lib/constable/storage/mysql_adapter.rb +125 -0
  34. data/lib/constable/storage/postgres_adapter.rb +125 -0
  35. data/lib/constable/storage/sqlite_adapter.rb +84 -0
  36. data/lib/constable/storage.rb +847 -0
  37. data/lib/constable/version.rb +5 -0
  38. data/lib/constable/warrants.rb +290 -0
  39. data/lib/constable-rails.rb +16 -0
  40. data/lib/constable.rb +151 -0
  41. data/lib/generators/constable/base.rb +99 -0
  42. data/lib/generators/constable/channel/channel_generator.rb +20 -0
  43. data/lib/generators/constable/channel/templates/channel_case.rb.tt +29 -0
  44. data/lib/generators/constable/controller/controller_generator.rb +25 -0
  45. data/lib/generators/constable/controller/templates/controller_case.rb.tt +32 -0
  46. data/lib/generators/constable/generator/generator_generator.rb +31 -0
  47. data/lib/generators/constable/generator/templates/generator_case.rb.tt +28 -0
  48. data/lib/generators/constable/helper/helper_generator.rb +23 -0
  49. data/lib/generators/constable/helper/templates/helper_case.rb.tt +19 -0
  50. data/lib/generators/constable/import_generator.rb +137 -0
  51. data/lib/generators/constable/install_generator.rb +188 -0
  52. data/lib/generators/constable/integration/integration_generator.rb +27 -0
  53. data/lib/generators/constable/integration/templates/request_case.rb.tt +22 -0
  54. data/lib/generators/constable/job/job_generator.rb +20 -0
  55. data/lib/generators/constable/job/templates/job_case.rb.tt +33 -0
  56. data/lib/generators/constable/mailbox/mailbox_generator.rb +20 -0
  57. data/lib/generators/constable/mailbox/templates/mailbox_case.rb.tt +26 -0
  58. data/lib/generators/constable/mailer/mailer_generator.rb +32 -0
  59. data/lib/generators/constable/mailer/templates/mailer_case.rb.tt +34 -0
  60. data/lib/generators/constable/mailer/templates/preview.rb.tt +14 -0
  61. data/lib/generators/constable/model/model_generator.rb +31 -0
  62. data/lib/generators/constable/model/templates/model_case.rb.tt +37 -0
  63. data/lib/generators/constable/resource/resource_generator.rb +27 -0
  64. data/lib/generators/constable/scaffold/scaffold_generator.rb +42 -0
  65. data/lib/generators/constable/scaffold/templates/api_controller_case.rb.tt +54 -0
  66. data/lib/generators/constable/scaffold/templates/controller_case.rb.tt +70 -0
  67. data/lib/generators/constable/scaffold/templates/system_case.rb.tt +53 -0
  68. data/lib/generators/constable/system/system_generator.rb +20 -0
  69. data/lib/generators/constable/system/templates/system_case.rb.tt +18 -0
  70. data/lib/generators/constable/templates/authenticatable.rb.tt +31 -0
  71. data/lib/generators/constable/templates/case_helper.rb.tt +179 -0
  72. data/lib/generators/constable/templates/config.yml.tt +67 -0
  73. data/lib/generators/constable/templates/example_case.rb.tt +56 -0
  74. data/lib/generators/constable/templates/matchers.rb.tt +36 -0
  75. data/lib/generators/constable/templates/rubocop.yml.tt +12 -0
  76. metadata +209 -0
@@ -0,0 +1,951 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "coverage"
4
+ require "set"
5
+ require "cgi"
6
+ require "rbconfig"
7
+ require "constable/diff"
8
+
9
+ module Constable
10
+ # Coverage -- "the beat".
11
+ #
12
+ # ---------------------------------------------------------------------------
13
+ # A NAMING TRAP, READ THIS FIRST
14
+ #
15
+ # This module is `Constable::Coverage`. Ruby's built-in line-coverage module is
16
+ # `::Coverage`. Inside `module Constable`, a bare `Coverage` resolves to *this* module,
17
+ # so `Coverage.start(lines: true)` written anywhere in this file would call a method on
18
+ # ourselves and fail in a way that reads like a bug in Ruby. **Every reference to the
19
+ # stdlib is spelled `::Coverage`, without exception.** Keep it that way.
20
+ # ---------------------------------------------------------------------------
21
+ #
22
+ # We wrap Ruby's own `Coverage` module rather than SimpleCov, and that is the whole
23
+ # trick behind coverage working uniformly here: **`::Coverage` measures the process, not
24
+ # the test framework.** It hooks line execution in the VM. It has no idea whether the
25
+ # line was reached by a native `investigate` block, by the real RSpec engine running a
26
+ # cold case, or by a `require` at boot. So native cases and cold cases contribute to one
27
+ # set of numbers automatically, with no per-engine instrumentation and nothing to keep
28
+ # in sync when a new engine adapter appears.
29
+ #
30
+ # What is *not* uniform is the gate. The enforced threshold is diff-based, not blanket:
31
+ # `coverage_threshold: 90` is checked only against lines changed versus the merge base,
32
+ # the same git-diff philosophy the Runner uses to decide what to run locally. Legacy
33
+ # gaps stay visible in the report without blocking the build; new code is held to the
34
+ # bar. And cold cases, which opt out of Constable's rules everywhere else, opt out here
35
+ # too: they contribute numbers, they are not held to the gate.
36
+ module Coverage
37
+ # Our own lib/, so the gem never measures itself.
38
+ GEM_LIB = File.expand_path("..", __dir__)
39
+
40
+ # Directory segments that are never application code.
41
+ EXCLUDED_DIRS = %w[
42
+ test spec features vendor tmp node_modules coverage log pkg
43
+ .git .constable .bundle
44
+ ].to_set.freeze
45
+
46
+ EXCLUDED_BASENAMES = %w[
47
+ case_helper.rb test_helper.rb spec_helper.rb rails_helper.rb
48
+ ].to_set.freeze
49
+
50
+ EXCLUDED_BASENAME_PATTERN = /_(?:test|spec)\.rb\z/
51
+
52
+ # Lines a heuristic scan treats as not executable, used only for files that never got
53
+ # loaded at all (see Report#synthesize_missing).
54
+ NON_EXECUTABLE = /\A(?:end|else|ensure|begin|rescue.*|\}|\)|\]|__END__)\z/
55
+
56
+ class << self
57
+ # Begins measuring, if the run asked for it. Returns true when measurement is
58
+ # active afterwards (whoever started it), false when coverage is switched off.
59
+ #
60
+ # Coverage.start!(config: Constable.config)
61
+ # Coverage.start!(config: config, force: true) # `constable beat`, ignoring the setting
62
+ #
63
+ # If something else -- SimpleCov in the app's own rails_helper, a CI wrapper -- has
64
+ # already started `::Coverage`, we attach to their measurement instead of starting a
65
+ # second one (there is only one, process-wide) and take care never to stop it out
66
+ # from under them.
67
+ def start!(config: Constable.config, force: false)
68
+ return false unless force || config.coverage?
69
+ return true if active?
70
+
71
+ if ::Coverage.running?
72
+ @external = true
73
+ else
74
+ ::Coverage.start(lines: true)
75
+ @external = false
76
+ end
77
+ @active = true
78
+ end
79
+
80
+ # Ends measurement and returns a Report, or nil if we were never measuring.
81
+ #
82
+ # report = Coverage.stop!(config: config)
83
+ # report = Coverage.stop!(config: config, gate: false) # cold-only run
84
+ # report = Coverage.stop!(config: config, exempt: cold_files) # per-file exemptions
85
+ def stop!(config: Constable.config, root: Constable.root, exempt: [], gate: true,
86
+ since: nil, changed_lines: :detect)
87
+ raw = harvest(stop: true)
88
+ return nil if raw.nil?
89
+
90
+ build_report(raw, config: config, root: root, exempt: exempt, gate: gate,
91
+ since: since, changed_lines: changed_lines)
92
+ end
93
+
94
+ # A Report from the numbers so far, leaving measurement running. Useful for a
95
+ # mid-run snapshot; harmless to call when coverage is off (returns nil).
96
+ def peek(config: Constable.config, root: Constable.root, exempt: [], gate: true,
97
+ since: nil, changed_lines: :detect)
98
+ raw = harvest(stop: false)
99
+ return nil if raw.nil?
100
+
101
+ build_report(raw, config: config, root: root, exempt: exempt, gate: gate,
102
+ since: since, changed_lines: changed_lines)
103
+ end
104
+
105
+ # Stops measurement without producing a report -- for teardown paths that just want
106
+ # the hook off again. Never stops a measurement somebody else started.
107
+ def abort!
108
+ ::Coverage.result(stop: true, clear: true) if @active && !@external && ::Coverage.running?
109
+ true
110
+ ensure
111
+ @active = false
112
+ @external = false
113
+ end
114
+
115
+ def active? = @active == true
116
+ def external? = @external == true
117
+
118
+ # Builds a Report from a raw `::Coverage` result hash. Public because it is the
119
+ # seam every test and every alternative front-end goes through: hand it a synthetic
120
+ # `{ "path.rb" => [1, 0, nil] }` and you get the same Report the real thing produces.
121
+ #
122
+ # raw - `::Coverage` result: { path => [hits...] } or { path => { lines: [...] } }
123
+ # exempt - paths/globs excluded from the diff gate (cold-case files)
124
+ # gate - false disables the diff gate entirely (a cold-case-only run)
125
+ # changed_lines - :detect asks Diff; nil means "no diff info"; a Hash is used as given
126
+ def build_report(raw, config: Constable.config, root: Constable.root, exempt: [], gate: true,
127
+ since: nil, changed_lines: :detect)
128
+ root = resolve_root(root)
129
+ files = normalize(raw, config: config, root: root)
130
+ changed = resolve_changed_lines(changed_lines, root: root, since: since)
131
+
132
+ Report.new(files: files, root: root, config: config, changed_lines: changed,
133
+ exempt: exempt, gate: gate)
134
+ end
135
+
136
+ # Persists one snapshot per run so `constable status` can plot a trend.
137
+ def record!(report, run_id, storage: Constable.storage)
138
+ return report if report.nil? || run_id.nil?
139
+
140
+ storage.record_coverage(run_id, percent: report.percent, files: report.files.map(&:to_h))
141
+ report
142
+ end
143
+
144
+ def default_html_path(root: Constable.root)
145
+ File.join(root.to_s, ".constable", "coverage", "index.html")
146
+ end
147
+
148
+ # Writes the browsable report and returns the path written.
149
+ def write_html(report, path: nil, root: Constable.root)
150
+ path ||= default_html_path(root: root)
151
+ require "fileutils"
152
+ FileUtils.mkdir_p(File.dirname(path))
153
+ File.write(path, report.to_html)
154
+ path
155
+ end
156
+
157
+ # Is this path application code we should be measuring? Excludes the gem itself,
158
+ # installed gems, the stdlib, and the suite's own test/spec files -- a test file's
159
+ # coverage of itself is noise, not information.
160
+ def application_file?(path, root: Constable.root, config: nil)
161
+ path = path.to_s
162
+ return false unless path.end_with?(".rb")
163
+
164
+ absolute = File.expand_path(path)
165
+ relative = relative_to(absolute, root)
166
+ return false if relative.nil?
167
+ return false if absolute.start_with?("#{GEM_LIB}/")
168
+ return false if absolute.include?("/gems/") || absolute.include?("/vendor/bundle/")
169
+ return false if stdlib?(absolute)
170
+
171
+ segments = relative.split("/")
172
+ basename = segments.pop
173
+ return false if segments.any? { |segment| EXCLUDED_DIRS.include?(segment) }
174
+ return false if EXCLUDED_BASENAMES.include?(basename) || EXCLUDED_BASENAME_PATTERN.match?(basename)
175
+
176
+ excluded = extra_excludes(config).any? do |glob|
177
+ File.fnmatch?(glob, relative, File::FNM_PATHNAME | File::FNM_EXTGLOB)
178
+ end
179
+ return false if excluded
180
+
181
+ true
182
+ end
183
+
184
+ # --- internals -----------------------------------------------------------
185
+
186
+ # `::Coverage` reports the paths Ruby actually loaded, which are symlink-resolved.
187
+ # A root of /var/folders/... and a coverage path of /private/var/folders/... are the
188
+ # same directory on macOS and must not be treated as different ones.
189
+ def resolve_root(root)
190
+ File.realpath(root.to_s)
191
+ rescue SystemCallError
192
+ File.expand_path(root.to_s)
193
+ end
194
+
195
+ # A path's location under the root, or nil when it isn't under it at all. Tries
196
+ # both spellings of the root -- symlinked (/var/...) and resolved (/private/var/...)
197
+ # -- because Ruby hands out one and the caller usually holds the other.
198
+ def relative_to(path, root)
199
+ absolute = File.expand_path(path.to_s)
200
+ [File.expand_path(root.to_s), resolve_root(root)].uniq.each do |prefix|
201
+ return absolute.delete_prefix("#{prefix}/") if absolute.start_with?("#{prefix}/")
202
+ end
203
+ nil
204
+ end
205
+
206
+ # The raw stdlib numbers as they stand, without ending the measurement. Workers use
207
+ # this to send their counts home: Ruby's Coverage is per-process, so a forked worker's
208
+ # hits exist only in that worker and would otherwise be thrown away when it exits.
209
+ def peek_raw
210
+ return nil unless ::Coverage.running?
211
+
212
+ ::Coverage.peek_result
213
+ rescue StandardError
214
+ nil
215
+ end
216
+
217
+ # Sums two raw coverage results. Line arrays are added element-wise; nil means the
218
+ # line isn't executable and stays nil, which is not the same as zero and must not
219
+ # become it -- a nil turned into a 0 invents an uncovered line that never existed.
220
+ def merge_raw(left, right)
221
+ merged = (left || {}).dup
222
+
223
+ (right || {}).each do |path, entry|
224
+ existing = merged[path]
225
+ merged[path] = existing.nil? ? entry : merge_entry(existing, entry)
226
+ end
227
+
228
+ merged
229
+ end
230
+
231
+ def merge_entry(left, right)
232
+ if left.is_a?(Hash) || right.is_a?(Hash)
233
+ lines = merge_lines(left.is_a?(Hash) ? left[:lines] : left,
234
+ right.is_a?(Hash) ? right[:lines] : right)
235
+ return (left.is_a?(Hash) ? left : right).merge(lines: lines)
236
+ end
237
+
238
+ merge_lines(left, right)
239
+ end
240
+
241
+ def merge_lines(left, right)
242
+ return right if left.nil?
243
+ return left if right.nil?
244
+
245
+ [left.size, right.size].max.times.map do |i|
246
+ a = left[i]
247
+ b = right[i]
248
+ next nil if a.nil? && b.nil?
249
+
250
+ a.to_i + b.to_i
251
+ end
252
+ end
253
+
254
+ # Reads the numbers out of the stdlib. When someone else owns the measurement we
255
+ # peek rather than stop, so their own report still comes out right at exit.
256
+ def harvest(stop:)
257
+ return nil unless active?
258
+ return nil unless ::Coverage.running?
259
+
260
+ if external? || !stop
261
+ ::Coverage.peek_result
262
+ else
263
+ ::Coverage.result(stop: true, clear: true)
264
+ end
265
+ ensure
266
+ if stop
267
+ @active = false
268
+ @external = false
269
+ end
270
+ end
271
+
272
+ def normalize(raw, config:, root:)
273
+ (raw || {}).filter_map do |path, entry|
274
+ path = real_path(path)
275
+ next unless application_file?(path, root: root, config: config)
276
+
277
+ lines = entry.is_a?(Hash) ? (entry[:lines] || entry["lines"]) : entry
278
+ next if lines.nil?
279
+
280
+ FileCoverage.new(path: path, lines: lines, root: root)
281
+ end
282
+ end
283
+
284
+ # `load "/var/tmp/x.rb"` is recorded by `::Coverage` under exactly the path it was
285
+ # given, symlinks and all. Resolving each measured file once keeps every later
286
+ # comparison -- against the root, against the diff -- an ordinary string compare.
287
+ def real_path(path)
288
+ File.realpath(path.to_s)
289
+ rescue SystemCallError
290
+ File.expand_path(path.to_s)
291
+ end
292
+
293
+ def resolve_changed_lines(changed_lines, root:, since:)
294
+ case changed_lines
295
+ when :detect then Diff.changed_lines(since: since, root: root, absolute: true)
296
+ when nil then nil
297
+ else normalize_changed_lines(changed_lines, root: root)
298
+ end
299
+ end
300
+
301
+ # Accepts relative or absolute keys, Arrays or Sets of line numbers.
302
+ def normalize_changed_lines(hash, root:)
303
+ hash.each_with_object({}) do |(path, numbers), out|
304
+ out[File.expand_path(path.to_s, root)] = numbers.is_a?(Set) ? numbers : Set.new(Array(numbers))
305
+ end
306
+ end
307
+
308
+ def stdlib?(absolute)
309
+ %w[rubylibdir libdir sitelibdir vendorlibdir archdir].any? do |key|
310
+ dir = RbConfig::CONFIG[key]
311
+ dir && !dir.empty? && absolute.start_with?("#{dir}/")
312
+ end
313
+ end
314
+
315
+ # Undocumented-but-supported escape valve: `coverage_exclude:` in config.yml takes
316
+ # a list of globs for generated or vendored code that lives inside the app tree.
317
+ def extra_excludes(config)
318
+ return [] unless config.respond_to?(:[])
319
+
320
+ Array(config["coverage_exclude"]).map(&:to_s)
321
+ end
322
+ end
323
+
324
+ # One file's line coverage. `lines` is `::Coverage`'s own array: an Integer hit count
325
+ # per executable line, nil for a line that can never execute (blank, comment, `end`).
326
+ class FileCoverage
327
+ attr_reader :path, :lines, :root
328
+
329
+ def initialize(path:, lines:, root: Constable.root)
330
+ @path = path.to_s
331
+ @lines = Array(lines)
332
+ @root = Coverage.resolve_root(root)
333
+ @synthesized = false
334
+ end
335
+
336
+ # True when this entry was inferred rather than measured -- a changed file that was
337
+ # never loaded at all, so `::Coverage` had nothing to say about it.
338
+ def synthesized? = @synthesized
339
+
340
+ def synthesized! # :nodoc:
341
+ @synthesized = true
342
+ self
343
+ end
344
+
345
+ def relative_path
346
+ @relative_path ||= Coverage.relative_to(@path, @root) || @path
347
+ end
348
+
349
+ # Number of executable lines.
350
+ def relevant = @lines.count { |hits| !hits.nil? }
351
+
352
+ # Number of executable lines that ran at least once.
353
+ def covered = @lines.count { |hits| !hits.nil? && hits.positive? }
354
+
355
+ def missed = relevant - covered
356
+
357
+ # 1-based line numbers, for the report and the gate.
358
+ def missed_lines = line_numbers { |hits| !hits.nil? && hits.zero? }
359
+ def covered_lines = line_numbers { |hits| !hits.nil? && hits.positive? }
360
+ def relevant_lines = line_numbers { |hits| !hits.nil? }
361
+
362
+ # 1-based line numbers whose hit count satisfies the block.
363
+ def line_numbers
364
+ numbers = []
365
+ @lines.each_with_index { |hits, index| numbers << (index + 1) if yield(hits) }
366
+ numbers
367
+ end
368
+
369
+ def hits_for(number) = @lines[number - 1]
370
+ def executable?(number) = !@lines[number - 1].nil?
371
+ def covered?(number) = (@lines[number - 1] || 0).positive?
372
+
373
+ # A file with no executable lines at all is vacuously complete, not a gap.
374
+ def percent
375
+ return 100.0 if relevant.zero?
376
+
377
+ ((covered.to_f / relevant) * 100).round(2)
378
+ end
379
+
380
+ # "Unpatrolled" -- zero executed lines. Named separately from a merely thin file
381
+ # because a 0% file is usually a file nobody remembered to test at all, and that is
382
+ # a different problem from a file whose edges are uncovered.
383
+ def unpatrolled? = relevant.positive? && covered.zero?
384
+
385
+ # The file's text, for the HTML line view. Missing/unreadable files render empty
386
+ # rather than blowing up a report someone is waiting on.
387
+ def source_lines
388
+ @source_lines ||= begin
389
+ File.readlines(@path, chomp: true).map { |line| line.dup.force_encoding(Encoding::UTF_8).scrub }
390
+ rescue SystemCallError, IOError
391
+ []
392
+ end
393
+ end
394
+
395
+ def to_h
396
+ {
397
+ path: relative_path, percent: percent, relevant: relevant,
398
+ covered: covered, missed: missed, unpatrolled: unpatrolled?,
399
+ missed_lines: missed_lines, synthesized: synthesized?
400
+ }
401
+ end
402
+ end
403
+
404
+ # The full picture for one run: overall numbers, per-file breakdown, the unpatrolled
405
+ # list, and the diff-based gate.
406
+ class Report
407
+ attr_reader :files, :root, :config, :exempt
408
+
409
+ def initialize(files:, root: Constable.root, config: Constable.config,
410
+ changed_lines: nil, exempt: [], gate: true)
411
+ @root = Coverage.resolve_root(root)
412
+ @config = config
413
+ @changed_lines = changed_lines
414
+ @exempt = Array(exempt).map(&:to_s)
415
+ @gate = gate
416
+ @files = files.sort_by(&:relative_path)
417
+ synthesize_missing!
418
+ end
419
+
420
+ # --- overall -------------------------------------------------------------
421
+
422
+ def empty? = @files.empty?
423
+ def file(path) = @files.find { |f| f.relative_path == path.to_s || f.path == File.expand_path(path.to_s, @root) }
424
+
425
+ def relevant = @files.sum(&:relevant)
426
+ def covered = @files.sum(&:covered)
427
+ def missed = relevant - covered
428
+
429
+ def percent
430
+ return 100.0 if relevant.zero?
431
+
432
+ ((covered.to_f / relevant) * 100).round(2)
433
+ end
434
+
435
+ def unpatrolled = @files.select(&:unpatrolled?)
436
+
437
+ # --- the diff gate -------------------------------------------------------
438
+
439
+ # False when git could not tell us what changed. The gate cannot be enforced then,
440
+ # and silently passing is the only honest answer -- we would otherwise fail builds
441
+ # on shallow clones and source tarballs.
442
+ def diff_available? = !@changed_lines.nil?
443
+
444
+ # Gating is switched off wholesale for a run that carried no native cases -- a
445
+ # `constable test --unsafe` run is all cold cases, and cold cases are exempt.
446
+ def gate? = @gate == true
447
+
448
+ # Files whose changed lines are held to the threshold: application files with
449
+ # changed lines, minus anything exempt (a cold-case file, or a path the Runner
450
+ # passed in as cold).
451
+ def gated_files
452
+ return [] unless diff_available?
453
+
454
+ @files.reject { |f| exempt?(f) }.select { |f| changed_numbers(f).any? }
455
+ end
456
+
457
+ # Changed, executable lines under the gate.
458
+ def diff_relevant
459
+ gated_files.sum { |f| changed_numbers(f).count { |n| f.executable?(n) } }
460
+ end
461
+
462
+ def diff_covered
463
+ gated_files.sum { |f| changed_numbers(f).count { |n| f.executable?(n) && f.covered?(n) } }
464
+ end
465
+
466
+ def diff_missed = diff_relevant - diff_covered
467
+
468
+ # Percentage of changed executable lines that ran. nil when there is no diff info;
469
+ # 100.0 when the diff touched no executable line at all (a README, a comment).
470
+ def diff_percent
471
+ return nil unless diff_available?
472
+ return 100.0 if diff_relevant.zero?
473
+
474
+ ((diff_covered.to_f / diff_relevant) * 100).round(2)
475
+ end
476
+
477
+ # { "app/models/user.rb" => [12, 13, 40] } -- the changed lines that never ran.
478
+ # This is the actionable half of the gate: not "you are at 84%", but "these lines".
479
+ def uncovered_diff_lines
480
+ gated_files.each_with_object({}) do |file, out|
481
+ numbers = changed_numbers(file).select { |n| file.executable?(n) && !file.covered?(n) }.sort
482
+ out[file.relative_path] = numbers if numbers.any?
483
+ end
484
+ end
485
+
486
+ def threshold = @config.respond_to?(:coverage_threshold) ? @config.coverage_threshold : 0
487
+
488
+ # The build gate. Passes when there is nothing to judge -- no git, no changed
489
+ # executable lines, or a run that was exempt from gating altogether.
490
+ def meets_threshold?(config = @config)
491
+ return true unless gate?
492
+ return true unless diff_available?
493
+ return true if diff_relevant.zero?
494
+
495
+ diff_percent >= (config.respond_to?(:coverage_threshold) ? config.coverage_threshold : 0)
496
+ end
497
+
498
+ # nil when the gate passes; otherwise the sentence the reporter should print.
499
+ def threshold_message(config = @config)
500
+ return nil if meets_threshold?(config)
501
+
502
+ limit = config.respond_to?(:coverage_threshold) ? config.coverage_threshold : 0
503
+ "diff coverage #{format_percent(diff_percent)} of #{diff_relevant} changed " \
504
+ "#{plural(diff_relevant, "line")} is below the #{limit}% threshold"
505
+ end
506
+
507
+ # --- output --------------------------------------------------------------
508
+
509
+ # The one line the run summary gains: "◐ 92% covered (3 files unpatrolled)".
510
+ def summary_line
511
+ line = "◐ #{percent.round}% covered"
512
+ count = unpatrolled.size
513
+ return line if count.zero?
514
+
515
+ "#{line} (#{count} #{plural(count, "file")} unpatrolled)"
516
+ end
517
+
518
+ # `constable beat` -- the standalone full picture. Plain text; colouring is the
519
+ # reporter's business, not ours.
520
+ def beat_report
521
+ rule = "━" * 60
522
+ out = [rule, " THE BEAT#{" " * 12}#{headline}", rule, ""]
523
+ out.concat(breakdown_section)
524
+ out.concat(unpatrolled_section)
525
+ out.concat(diff_section)
526
+ out << rule
527
+ out.join("\n")
528
+ end
529
+
530
+ def to_h
531
+ {
532
+ percent: percent, covered: covered, relevant: relevant,
533
+ unpatrolled: unpatrolled.map(&:relative_path),
534
+ diff_percent: diff_percent, diff_covered: diff_covered, diff_relevant: diff_relevant,
535
+ uncovered_diff_lines: uncovered_diff_lines,
536
+ meets_threshold: meets_threshold?,
537
+ files: @files.map(&:to_h)
538
+ }
539
+ end
540
+
541
+ def to_html = Html.new(self).render
542
+
543
+ # --- internals -----------------------------------------------------------
544
+
545
+ def changed_numbers(file)
546
+ return [] unless @changed_lines
547
+
548
+ Array(@changed_lines[file.path] ||
549
+ @changed_lines[File.expand_path(file.relative_path, @root)] ||
550
+ @changed_lines[file.relative_path])
551
+ end
552
+
553
+ def exempt?(file)
554
+ relative = file.relative_path
555
+ return true if @config.respond_to?(:cold_case?) && @config.cold_case?(relative)
556
+
557
+ @exempt.any? do |pattern|
558
+ pattern == relative || pattern == file.path ||
559
+ File.fnmatch?(pattern, relative, File::FNM_PATHNAME | File::FNM_EXTGLOB)
560
+ end
561
+ end
562
+
563
+ private
564
+
565
+ # `::Coverage` only knows about files that were *loaded*. Add a brand-new class that
566
+ # no test ever requires and it simply will not appear in the result -- and a diff
567
+ # gate that silently ignores the one file you just wrote is worse than no gate. So
568
+ # for changed application files with no measurement at all, we synthesize a
569
+ # zero-coverage entry, guessing executable lines from the source. The guess is
570
+ # crude on purpose; a file in this state is 0% covered whichever way you count it.
571
+ def synthesize_missing!
572
+ return unless @changed_lines
573
+
574
+ known = @files.to_set(&:path)
575
+ @changed_lines.each_key do |path|
576
+ next if known.include?(path)
577
+ next unless File.file?(path)
578
+ next unless Coverage.application_file?(path, root: @root, config: @config)
579
+
580
+ @files << FileCoverage.new(path: path, lines: guess_executable(path), root: @root).synthesized!
581
+ end
582
+ @files.sort_by!(&:relative_path)
583
+ end
584
+
585
+ def guess_executable(path)
586
+ File.readlines(path, chomp: true).map do |line|
587
+ stripped = line.strip
588
+ next nil if stripped.empty? || stripped.start_with?("#")
589
+ next nil if NON_EXECUTABLE.match?(stripped)
590
+
591
+ 0
592
+ end
593
+ rescue SystemCallError, IOError
594
+ []
595
+ end
596
+
597
+ def headline
598
+ parts = ["#{format_percent(percent)} covered", "#{@files.size} #{plural(@files.size, "file")}"]
599
+ parts << "#{unpatrolled.size} unpatrolled" if unpatrolled.any?
600
+ parts << "diff #{format_percent(diff_percent)}" if diff_available? && diff_relevant.positive?
601
+ parts.join(" · ")
602
+ end
603
+
604
+ def breakdown_section
605
+ return [" no application files measured", ""] if @files.empty?
606
+
607
+ width = @files.map { |f| f.relative_path.length }.max
608
+ rows = @files.sort_by { |f| [f.percent, f.relative_path] }.map do |f|
609
+ format(" %6s %-#{width}s %s/%s%s", format_percent(f.percent), f.relative_path,
610
+ f.covered, f.relevant, f.synthesized? ? " (never loaded)" : "")
611
+ end
612
+ [" COVERAGE", " #{"─" * 8}", *rows, ""]
613
+ end
614
+
615
+ def unpatrolled_section
616
+ return [] if unpatrolled.empty?
617
+
618
+ rows = unpatrolled.map do |f|
619
+ " ○ #{f.relative_path} (0 of #{f.relevant} #{plural(f.relevant, "line")})"
620
+ end
621
+ [" UNPATROLLED", " #{"─" * 11}", *rows, ""]
622
+ end
623
+
624
+ def diff_section
625
+ return [" DIFF COVERAGE", " #{"─" * 13}", " no diff information available", ""] unless diff_available?
626
+ return [] if diff_relevant.zero? && uncovered_diff_lines.empty?
627
+
628
+ verdict = meets_threshold? ? "meets" : "below"
629
+ rows = [" #{format_percent(diff_percent)} of #{diff_relevant} changed " \
630
+ "#{plural(diff_relevant, "line")} covered -- #{verdict} the #{threshold}% threshold"]
631
+ uncovered_diff_lines.each { |path, numbers| rows << " ✗ #{path}:#{numbers.join(",")}" }
632
+ [" DIFF COVERAGE", " #{"─" * 13}", *rows, ""]
633
+ end
634
+
635
+ def format_percent(value)
636
+ return "n/a" if value.nil?
637
+
638
+ value == value.round ? "#{value.round}%" : "#{format("%.1f", value)}%"
639
+ end
640
+
641
+ def plural(count, word) = count == 1 ? word : "#{word}s"
642
+ end
643
+
644
+ # The browsable report: one self-contained HTML file, no CDN, no assets directory,
645
+ # nothing to serve. Open it from a file:// URL on a plane and it still works.
646
+ class Html
647
+ def initialize(report)
648
+ @report = report
649
+ end
650
+
651
+ def render
652
+ <<~HTML
653
+ <!doctype html>
654
+ <html lang="en">
655
+ <head>
656
+ <meta charset="utf-8">
657
+ <meta name="viewport" content="width=device-width, initial-scale=1">
658
+ <title>Constable — the beat</title>
659
+ <style>#{CSS}</style>
660
+ </head>
661
+ <body>
662
+ <header class="beat">
663
+ #{header}
664
+ </header>
665
+ <main>
666
+ #{diff_panel}
667
+ #{unpatrolled_panel}
668
+ #{table}
669
+ </main>
670
+ <footer>Generated by Constable — click a filename for its line-by-line view.</footer>
671
+ <script>#{JS}</script>
672
+ </body>
673
+ </html>
674
+ HTML
675
+ end
676
+
677
+ private
678
+
679
+ def e(text) = CGI.escapeHTML(text.to_s)
680
+
681
+ def header
682
+ percent = @report.percent
683
+ <<~HTML
684
+ <div class="dial #{grade(percent)}">
685
+ <span class="pct">#{percent.round}%</span>
686
+ <span class="lbl">covered</span>
687
+ </div>
688
+ <div class="stats">
689
+ <h1>The beat</h1>
690
+ <p>
691
+ <strong>#{@report.covered}</strong> of <strong>#{@report.relevant}</strong> relevant lines
692
+ across <strong>#{@report.files.size}</strong> files.
693
+ <span class="chip #{@report.unpatrolled.empty? ? "ok" : "warn"}">#{@report.unpatrolled.size} unpatrolled</span>
694
+ #{diff_chip}
695
+ </p>
696
+ </div>
697
+ HTML
698
+ end
699
+
700
+ def diff_chip
701
+ return "" unless @report.diff_available?
702
+ return %(<span class="chip ok">no changed lines</span>) if @report.diff_relevant.zero?
703
+
704
+ state = @report.meets_threshold? ? "ok" : "bad"
705
+ %(<span class="chip #{state}">diff #{@report.diff_percent.round}% of ) +
706
+ %(#{@report.diff_relevant} changed lines</span>)
707
+ end
708
+
709
+ def diff_panel
710
+ return "" unless @report.diff_available?
711
+
712
+ uncovered = @report.uncovered_diff_lines
713
+ return "" if uncovered.empty?
714
+
715
+ rows = uncovered.map do |path, numbers|
716
+ links = numbers.map { |n| %(<a href="#" data-goto="#{e(path)}" data-line="#{n}">#{n}</a>) }.join(", ")
717
+ %(<li><code>#{e(path)}</code> <span class="lines">#{links}</span></li>)
718
+ end.join("\n")
719
+
720
+ <<~HTML
721
+ <section class="panel #{@report.meets_threshold? ? "ok" : "bad"}">
722
+ <h2>Changed lines not covered</h2>
723
+ <p class="sub">Only these lines are held to the #{@report.threshold}% threshold. Legacy gaps below are visible, not blocking.</p>
724
+ <ul class="uncovered">#{rows}</ul>
725
+ </section>
726
+ HTML
727
+ end
728
+
729
+ def unpatrolled_panel
730
+ files = @report.unpatrolled
731
+ return "" if files.empty?
732
+
733
+ items = files.map do |f|
734
+ %(<li><a href="#" data-goto="#{e(f.relative_path)}">#{e(f.relative_path)}</a> ) +
735
+ %(<span class="sub">#{f.relevant} lines, none executed</span></li>)
736
+ end.join("\n")
737
+
738
+ <<~HTML
739
+ <section class="panel warn">
740
+ <h2>Unpatrolled</h2>
741
+ <p class="sub">Zero executed lines. Usually a file nobody tested at all, rather than a thin one.</p>
742
+ <ul class="uncovered">#{items}</ul>
743
+ </section>
744
+ HTML
745
+ end
746
+
747
+ def table
748
+ rows = @report.files.each_with_index.map { |file, index| file_rows(file, index) }.join("\n")
749
+ <<~HTML
750
+ <table id="files">
751
+ <thead>
752
+ <tr>
753
+ <th data-sort="text" class="asc">File</th>
754
+ <th data-sort="num">Coverage</th>
755
+ <th data-sort="num">Covered</th>
756
+ <th data-sort="num">Relevant</th>
757
+ <th data-sort="num">Missed</th>
758
+ </tr>
759
+ </thead>
760
+ <tbody>#{rows}</tbody>
761
+ </table>
762
+ HTML
763
+ end
764
+
765
+ def file_rows(file, index)
766
+ percent = file.percent
767
+ note = file.synthesized? ? %( <span class="chip warn">never loaded</span>) : ""
768
+ <<~HTML
769
+ <tr class="file #{grade(percent)}" data-file="#{e(file.relative_path)}" data-index="#{index}">
770
+ <td class="name"><button type="button" class="toggle" aria-expanded="false">#{e(file.relative_path)}</button>#{note}</td>
771
+ <td data-value="#{percent}"><span class="bar"><span style="width:#{percent}%"></span></span><span class="num">#{format("%.1f", percent)}%</span></td>
772
+ <td data-value="#{file.covered}" class="num">#{file.covered}</td>
773
+ <td data-value="#{file.relevant}" class="num">#{file.relevant}</td>
774
+ <td data-value="#{file.missed}" class="num">#{file.missed}</td>
775
+ </tr>
776
+ <tr class="source" hidden><td colspan="5">#{source_view(file)}</td></tr>
777
+ HTML
778
+ end
779
+
780
+ def source_view(file)
781
+ lines = file.source_lines
782
+ return %(<p class="sub">Source unavailable (#{e(file.path)}).</p>) if lines.empty?
783
+
784
+ body = lines.each_with_index.map do |text, index|
785
+ number = index + 1
786
+ hits = file.hits_for(number)
787
+ state = if hits.nil? then "na"
788
+ elsif hits.positive? then "hit"
789
+ else "miss"
790
+ end
791
+ count = hits.nil? ? "" : "#{hits}×"
792
+ %(<div class="line #{state}" id="L#{e(file.relative_path)}-#{number}">) +
793
+ %(<span class="ln">#{number}</span><span class="hits">#{count}</span><code>#{e(text)}</code></div>)
794
+ end.join
795
+
796
+ %(<div class="code">#{body}</div>)
797
+ end
798
+
799
+ def grade(percent)
800
+ return "good" if percent >= 90
801
+ return "ok" if percent >= 70
802
+
803
+ percent.zero? ? "none" : "bad"
804
+ end
805
+
806
+ CSS = <<~CSS
807
+ :root {
808
+ color-scheme: light dark;
809
+ --bg: #f6f5f2; --panel: #fffdf8; --ink: #1c1a17; --muted: #6c665c;
810
+ --line: #e3ded3; --good: #2e7d4f; --ok: #b07d12; --bad: #b3261e; --none: #7a2f2a;
811
+ --hit: rgba(46,125,79,.14); --miss: rgba(179,38,30,.16); --accent: #2d4f8a;
812
+ }
813
+ @media (prefers-color-scheme: dark) {
814
+ :root {
815
+ --bg: #16171a; --panel: #1e2024; --ink: #e8e6e1; --muted: #9a958c;
816
+ --line: #2e3137; --good: #6bbd8c; --ok: #d8a93e; --bad: #ef6b62; --none: #ef8b7f;
817
+ --hit: rgba(107,189,140,.16); --miss: rgba(239,107,98,.18); --accent: #7fa8f0;
818
+ }
819
+ }
820
+ * { box-sizing: border-box; }
821
+ body { margin: 0; background: var(--bg); color: var(--ink);
822
+ font: 14px/1.5 ui-sans-serif, -apple-system, "Segoe UI", Roboto, sans-serif; }
823
+ header.beat { display: flex; gap: 1.5rem; align-items: center; padding: 1.5rem 2rem;
824
+ border-bottom: 1px solid var(--line); background: var(--panel); }
825
+ .dial { width: 96px; height: 96px; border-radius: 50%; display: flex; flex-direction: column;
826
+ align-items: center; justify-content: center; border: 4px solid var(--line); flex: none; }
827
+ .dial.good { border-color: var(--good); } .dial.ok { border-color: var(--ok); }
828
+ .dial.bad, .dial.none { border-color: var(--bad); }
829
+ .dial .pct { font-size: 1.6rem; font-weight: 700; }
830
+ .dial .lbl { font-size: .7rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
831
+ h1 { font-size: 1.25rem; margin: 0 0 .25rem; }
832
+ h2 { font-size: 1rem; margin: 0 0 .25rem; }
833
+ p { margin: .25rem 0; }
834
+ .sub { color: var(--muted); font-size: .85rem; }
835
+ .chip { display: inline-block; padding: .1rem .5rem; border-radius: 999px; font-size: .78rem;
836
+ border: 1px solid var(--line); }
837
+ .chip.ok { color: var(--good); border-color: var(--good); }
838
+ .chip.warn { color: var(--ok); border-color: var(--ok); }
839
+ .chip.bad { color: var(--bad); border-color: var(--bad); }
840
+ main { padding: 1.5rem 2rem 3rem; }
841
+ .panel { background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--muted);
842
+ border-radius: 6px; padding: 1rem 1.25rem; margin-bottom: 1.25rem; }
843
+ .panel.bad { border-left-color: var(--bad); }
844
+ .panel.warn { border-left-color: var(--ok); }
845
+ ul.uncovered { list-style: none; margin: .5rem 0 0; padding: 0; }
846
+ ul.uncovered li { padding: .2rem 0; border-bottom: 1px dotted var(--line); }
847
+ .lines a { color: var(--accent); text-decoration: none; margin-right: .25rem; }
848
+ table { width: 100%; border-collapse: collapse; background: var(--panel);
849
+ border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
850
+ th { text-align: left; font-size: .78rem; text-transform: uppercase; letter-spacing: .06em;
851
+ color: var(--muted); padding: .6rem .75rem; border-bottom: 1px solid var(--line);
852
+ cursor: pointer; user-select: none; white-space: nowrap; }
853
+ th::after { content: ""; }
854
+ th.asc::after { content: " ▲"; } th.desc::after { content: " ▼"; }
855
+ td { padding: .4rem .75rem; border-bottom: 1px solid var(--line); vertical-align: middle; }
856
+ td.num, th:not(:first-child) { text-align: right; }
857
+ tr.file.good td:first-child { border-left: 3px solid var(--good); }
858
+ tr.file.ok td:first-child { border-left: 3px solid var(--ok); }
859
+ tr.file.bad td:first-child, tr.file.none td:first-child { border-left: 3px solid var(--bad); }
860
+ button.toggle { background: none; border: 0; padding: 0; color: var(--accent); cursor: pointer;
861
+ font: inherit; text-align: left; }
862
+ .bar { display: inline-block; width: 90px; height: 6px; background: var(--line);
863
+ border-radius: 3px; overflow: hidden; margin-right: .5rem; vertical-align: middle; }
864
+ .bar > span { display: block; height: 100%; background: var(--good); }
865
+ tr.file.ok .bar > span { background: var(--ok); }
866
+ tr.file.bad .bar > span, tr.file.none .bar > span { background: var(--bad); }
867
+ tr.source > td { padding: 0; background: var(--bg); }
868
+ .code { max-height: 60vh; overflow: auto; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
869
+ .line { display: flex; white-space: pre; }
870
+ .line .ln { width: 4.5rem; flex: none; text-align: right; padding-right: .75rem; color: var(--muted); }
871
+ .line .hits { width: 4rem; flex: none; text-align: right; padding-right: 1rem; color: var(--muted); font-size: .9em; }
872
+ .line code { white-space: pre; }
873
+ .line.hit { background: var(--hit); }
874
+ .line.miss { background: var(--miss); }
875
+ .line.miss .ln { color: var(--bad); font-weight: 700; }
876
+ .line.na { opacity: .65; }
877
+ .line.flash { outline: 2px solid var(--accent); outline-offset: -2px; }
878
+ footer { padding: 1rem 2rem 2rem; color: var(--muted); font-size: .8rem; }
879
+ CSS
880
+
881
+ JS = <<~JS
882
+ (function () {
883
+ var table = document.getElementById("files");
884
+ if (!table) return;
885
+ var body = table.tBodies[0];
886
+
887
+ // Rows come in pairs: the file row and its (hidden) source row. Sorting moves
888
+ // the pair, never just the header of it.
889
+ function pairs() {
890
+ var out = [], rows = Array.prototype.slice.call(body.rows);
891
+ for (var i = 0; i < rows.length; i += 2) out.push([rows[i], rows[i + 1]]);
892
+ return out;
893
+ }
894
+
895
+ function toggle(row) {
896
+ var source = row.nextElementSibling;
897
+ var button = row.querySelector(".toggle");
898
+ var open = source.hidden;
899
+ source.hidden = !open;
900
+ if (button) button.setAttribute("aria-expanded", open ? "true" : "false");
901
+ return source;
902
+ }
903
+
904
+ body.addEventListener("click", function (event) {
905
+ var button = event.target.closest(".toggle");
906
+ if (!button) return;
907
+ event.preventDefault();
908
+ toggle(button.closest("tr.file"));
909
+ });
910
+
911
+ Array.prototype.forEach.call(table.tHead.rows[0].cells, function (th, index) {
912
+ th.addEventListener("click", function () {
913
+ var descending = !th.classList.contains("desc");
914
+ Array.prototype.forEach.call(table.tHead.rows[0].cells, function (other) {
915
+ other.classList.remove("asc", "desc");
916
+ });
917
+ th.classList.add(descending ? "desc" : "asc");
918
+ var numeric = th.dataset.sort === "num";
919
+ var sorted = pairs().sort(function (a, b) {
920
+ var x = a[0].cells[index], y = b[0].cells[index];
921
+ if (numeric) {
922
+ return (parseFloat(y.dataset.value) - parseFloat(x.dataset.value)) * (descending ? 1 : -1);
923
+ }
924
+ return x.textContent.trim().localeCompare(y.textContent.trim()) * (descending ? -1 : 1);
925
+ });
926
+ sorted.forEach(function (pair) { body.appendChild(pair[0]); body.appendChild(pair[1]); });
927
+ });
928
+ });
929
+
930
+ document.addEventListener("click", function (event) {
931
+ var link = event.target.closest("[data-goto]");
932
+ if (!link) return;
933
+ event.preventDefault();
934
+ var row = body.querySelector('tr.file[data-file="' + link.dataset.goto + '"]');
935
+ if (!row) return;
936
+ if (row.nextElementSibling.hidden) toggle(row);
937
+ var target = link.dataset.line
938
+ ? document.getElementById("L" + link.dataset.goto + "-" + link.dataset.line)
939
+ : row;
940
+ if (!target) target = row;
941
+ target.scrollIntoView({ block: "center" });
942
+ target.classList.add("flash");
943
+ setTimeout(function () { target.classList.remove("flash"); }, 1600);
944
+ });
945
+ })();
946
+ JS
947
+
948
+ private_constant :CSS, :JS
949
+ end
950
+ end
951
+ end