path_coverage 0.0.1 → 0.0.2

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.md +22 -0
  3. data/README.md +316 -0
  4. data/exe/path_coverage_collector +26 -0
  5. data/lib/path_coverage/auto.rb +5 -0
  6. data/lib/path_coverage/collector.rb +46 -0
  7. data/lib/path_coverage/condition.rb +45 -0
  8. data/lib/path_coverage/configuration.rb +127 -0
  9. data/lib/path_coverage/gaps.rb +60 -0
  10. data/lib/path_coverage/html/file.html +33 -0
  11. data/lib/path_coverage/html/index.html +38 -0
  12. data/lib/path_coverage/html/side.js +531 -0
  13. data/lib/path_coverage/html/sort.js +28 -0
  14. data/lib/path_coverage/html/style.css +334 -0
  15. data/lib/path_coverage/html/toggle.js +12 -0
  16. data/lib/path_coverage/html_file.rb +109 -0
  17. data/lib/path_coverage/html_index.rb +43 -0
  18. data/lib/path_coverage/html_nav.rb +67 -0
  19. data/lib/path_coverage/html_paths.rb +138 -0
  20. data/lib/path_coverage/html_report.rb +103 -0
  21. data/lib/path_coverage/html_template.rb +20 -0
  22. data/lib/path_coverage/html_tree.rb +137 -0
  23. data/lib/path_coverage/json_writer.rb +21 -0
  24. data/lib/path_coverage/kernel.rb +179 -0
  25. data/lib/path_coverage/manifest.rb +177 -0
  26. data/lib/path_coverage/manifest_choices.rb +113 -0
  27. data/lib/path_coverage/merge.rb +35 -0
  28. data/lib/path_coverage/path_gaps.rb +41 -0
  29. data/lib/path_coverage/path_manifest.rb +110 -0
  30. data/lib/path_coverage/paths_report.rb +55 -0
  31. data/lib/path_coverage/post.rb +26 -0
  32. data/lib/path_coverage/recorder.rb +99 -0
  33. data/lib/path_coverage/render.rb +92 -0
  34. data/lib/path_coverage/result.rb +59 -0
  35. data/lib/path_coverage/rewriter.rb +120 -0
  36. data/lib/path_coverage/site.rb +11 -0
  37. data/lib/path_coverage/site_paths.rb +74 -0
  38. data/lib/path_coverage/site_tree.rb +94 -0
  39. data/lib/path_coverage/source_lines.rb +79 -0
  40. data/lib/path_coverage/summary.rb +33 -0
  41. data/lib/path_coverage/version.rb +3 -0
  42. data/lib/path_coverage.rb +2 -5
  43. metadata +68 -13
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dc1af74670e1171318d1d58fbd6a27f19b89c01ac95ee556f4e6bac3afa2bfbf
4
- data.tar.gz: 2ecf0d75608a054914d31e426c77f75cc607ac578671a8e93fe4494efd040ea2
3
+ metadata.gz: acac92dac750f3680a27856c6e410469f26c48c6ec23d6363d8b22caba5bc30c
4
+ data.tar.gz: c19de09501f409d8209ddb1071f37e44b926d9a4381ec302a6c460961ff162f8
5
5
  SHA512:
6
- metadata.gz: 41b26c437c12f9b6aa7ab78d3a4a50a2fd532d2a8db0923813a059943fc0289d3421a78e754c633db0ea20962e42564031b98453c2e2ad6cb6d61cef81557ac8
7
- data.tar.gz: 52b79781e54c09a07d1b6733ef3356ba8aac5cc87881fdb0303aae99842d2ef48cf00e555e29f11215fc8c1516986bf2f8bff02fdb5d539da3f48c0e688d1c79
6
+ metadata.gz: 2ec2945b4797a6b1f51f558c2cf2348307e1b6da68e5812c8ad4dfcab51f85926716f425c695cfd7456fec679d17fabf2e6b1400f7f6e131b0ccd9608aed5bce
7
+ data.tar.gz: 196816d6db1d908d2c578f7c547a9464025187d03c344537b741106faee39aa6c0c1127759456989442497fbc7a33f773a0ca096f92e5aaa599cbec9949f27f6
data/LICENSE.md ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2026, [CyberDojo Foundation](http://blog.cyber-dojo.org/2015/08/cyber-dojo-foundation.html)
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ Redistributions of source code must retain the above copyright notice, this
8
+ list of conditions and the following disclaimer.
9
+ Redistributions in binary form must reproduce the above copyright notice,
10
+ this list of conditions and the following disclaimer in the documentation
11
+ and/or other materials provided with the distribution.
12
+
13
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
14
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
15
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
16
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
17
+ ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
18
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
19
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
20
+ ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
21
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
22
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,316 @@
1
+ # path_coverage
2
+
3
+ Path coverage for Ruby, to run alongside SimpleCov's line and branch
4
+ coverage.
5
+
6
+ Gem: https://rubygems.org/gems/path_coverage (which shows the latest
7
+ version).
8
+
9
+ Status: sites cover `&&`, `||`, `and`, `or`, the ternary `?:`, and
10
+ `if`/`elsif`/`else`: each leaf operand of a condition, however its
11
+ operators are chained, nested or grouped. Method paths cover `if`
12
+ decisions. Decisions hidden inside calls come next (see Roadmap).
13
+
14
+ Demo: https://cyber-dojo.github.io/path_coverage/ shows the HTML report
15
+ for the mock source tree in `demo/`. `make demo` builds and opens the
16
+ same report locally.
17
+
18
+ Take this method:
19
+
20
+ ```ruby
21
+ def fee(user, order)
22
+ total = 100
23
+ if user.member? && order.paid?
24
+ total -= 5
25
+ end
26
+ if order.big?
27
+ total -= 10
28
+ end
29
+ total
30
+ end
31
+ ```
32
+
33
+ Tests that run both `if`s both ways reach every line and every branch,
34
+ so SimpleCov, which reads Ruby's built-in `Coverage`, reports 100%. Yet
35
+ they can still miss paths, at two scopes, and path_coverage lists each
36
+ path no test took.
37
+
38
+ ## Within each decision
39
+
40
+ The first `if` can go three ways: member and paid, member and not
41
+ paid, not a member. A member who has not paid may never have been
42
+ tried.
43
+
44
+ ![The first if's grid: a column per way it can go, each headed by
45
+ its count, the member who has not paid at 0](docs/decision.png)
46
+
47
+ ## Across a method's decisions
48
+
49
+ The two `if`s can go four ways together. A paying member with a small
50
+ order may never have been tried.
51
+
52
+ ![fee's grid: a column per way its two ifs can go together, each
53
+ headed by its count, true then false at 0 and selected](docs/method.png)
54
+
55
+ ## Use
56
+
57
+ ```ruby
58
+ # test coverage config, before any application code is required
59
+ require 'simplecov'
60
+ require 'path_coverage'
61
+ SimpleCov.start do
62
+ # ...
63
+ end
64
+ PathCoverage.start do
65
+ root APP_DIR # cover globs and site names are relative to it
66
+ cover 'source/**/*.rb' # each matching file is instrumented as it loads
67
+ coverage_dir '/tmp/coverage' # where path_coverage.json is written
68
+ measure :conditions, :paths # sites alone unless :paths is named too
69
+ report :gaps, :html # the reporters run at exit (default :gaps)
70
+ end
71
+ ```
72
+
73
+ A misspelt setting or an unknown reporter is refused by name, with the list
74
+ of the ones there are. Calling `start` again replaces the configuration.
75
+
76
+ A run whose code cannot be changed, such as a script in a `docker build`
77
+ step, can be covered from its environment alone:
78
+
79
+ ```sh
80
+ RUBYOPT=-rpath_coverage/auto
81
+ PATH_COVERAGE_ROOT=/app # root
82
+ PATH_COVERAGE_COVER=src/**/*.rb,lib/*.rb # cover, comma-separated
83
+ PATH_COVERAGE_DIR=/tmp/paths # coverage_dir
84
+ PATH_COVERAGE_COLLECTOR=http://host.docker.internal:4570/runs
85
+ ```
86
+
87
+ This starts with `report :post`. With no `PATH_COVERAGE_COLLECTOR` it
88
+ starts nothing and prints nothing, so an image can set `RUBYOPT` for every
89
+ run and measure only those given a collector.
90
+
91
+ The gem's `path_coverage_collector` command is that collector. It saves
92
+ each report POSTed to it as a numbered file, and once every run is done
93
+ they render as one report:
94
+
95
+ ```sh
96
+ path_coverage_collector 4570 /tmp/paths/runs # until stopped
97
+ ```
98
+
99
+ ```ruby
100
+ runs = Dir['/tmp/paths/runs/*.json']
101
+ PathCoverage::Render.from_json(config, runs)
102
+ ```
103
+
104
+ ## What a run reports
105
+
106
+ As the process exits:
107
+
108
+ - `path_coverage.json` is always written to `coverage_dir`: every site
109
+ of every covered file, with each operand's true and false counts under
110
+ `sites`, and each site's paths under `paths`. It is the base the other
111
+ reporters read, so they can also be run later, in another process, from
112
+ it alone: `PathCoverage::Render.from_json(config)`, where `config` is a
113
+ `PathCoverage::Configuration` built from the same settings block.
114
+ Given several runs' reports, `Render.from_json(config, paths)` adds
115
+ their counts together first, so the report shows only the gaps that
116
+ every run left.
117
+ - `:gaps` prints one line per path never taken, in manifest order: a
118
+ site's naming the outcome of each leaf on it, a method's the outcome
119
+ of each decision on it, by line:
120
+
121
+ ```text
122
+ source/kata.rb:3:23 path never taken: `ok?(id)` true, `new?(id)` false
123
+ source/kata.rb:4:24 never reached
124
+ source/fee.rb:2:2 fee path never taken: line 4 true, line 7 false
125
+ ```
126
+
127
+ ### Sites
128
+
129
+ A site is named by the `path:line:column` where it starts, the path
130
+ relative to `root`. A site in a covered file the run never loaded is
131
+ still reported, as `never reached`.
132
+
133
+ A path is the outcome of each leaf one evaluation of the site reached, in
134
+ order; a leaf that short-circuiting skipped is not on it. Each leaf is
135
+ quoted as a Markdown code span, on one line, its fence one backtick longer
136
+ than any run of backticks inside it. The gap lines are for people; a tool
137
+ should read the JSON, whose `paths` give each site's leaves as
138
+ `line:column` spans, and each possible path with its count:
139
+
140
+ ```json
141
+ "paths": {
142
+ "source/kata.rb:3:23": {
143
+ "leaves": [["3:23", "3:30"], ["3:34", "3:42"]],
144
+ "taken": [
145
+ { "path": [true, true], "count": 2 },
146
+ { "path": [true, false], "count": 0 },
147
+ { "path": [false, null], "count": 1 }
148
+ ]
149
+ }
150
+ }
151
+ ```
152
+
153
+ A site is an outermost condition. Its leaves are numbered left to right
154
+ across the whole of it, nested conditions included:
155
+
156
+ ```ruby
157
+ ok?(id) && new?(id) && id.size == 6 # one && site, leaves 0 1 2
158
+ ok?(id) && (new?(id) || id.size == 6) # an || nested in an && site, 0 1 2
159
+ ok?(id) && new?(id) || id.size == 6 # an && nested in an || site, 0 1 2
160
+ ids.any? { |k| k && k.ok? } && ok?(id) # two sites: the block's is its own
161
+ ok?(id) ? :ok : :no # a ?: site, its predicate leaf 0
162
+ ok?(id) && new?(id) ? :ok : :no # an && nested in a ?: site, 0 1
163
+ ok?(id) ? new?(id) && id.size == 6 : :no # one ?: site, its then arm 1 2
164
+ ```
165
+
166
+ A ternary is a site even when its predicate is a single leaf. An arm
167
+ holding a condition of `&&`, `||`, `and`, `or` or `?:` is part of the
168
+ ternary's site, its leaves numbered after the predicate's, so the site's
169
+ paths run through whichever arm the predicate picks. Any other arm, like a
170
+ block, is not part of it, though a condition inside it is a site of its
171
+ own.
172
+
173
+ An `if` (with its `end`, not a modifier) is a site too, even when its
174
+ predicate is a single leaf: its predicate nests in it, an `&&` there
175
+ being no site of its own, and its `then` and `else` arms are plain, as
176
+ they are statements. Its extent is the `if` keyword to the end of its
177
+ predicate, so a site inside an arm keeps its own button. An `elsif` is a
178
+ site of its own.
179
+
180
+ A chain of one operator is flat; a change of operator, by precedence or by
181
+ parentheses, nests. Parentheses are not part of the tree, since they decide
182
+ nothing.
183
+
184
+ A leaf whose form allows one outcome only needs only to be evaluated: a
185
+ path needing its other outcome is impossible, not a gap. `Foo.new`,
186
+ literals (`'s'`, `1`, `:k`, `[]`, `{}`, `true`) and lambdas can only be
187
+ truthy, as can `$stdout` and `$stderr`, which Ruby refuses to set to nil
188
+ or false. Other globals, `$stdin` among them, can be either, as can
189
+ constants, since one can hold nil or false (`VERBOSE = false`). `nil` and
190
+ `false` can only be falsy:
191
+
192
+ ```ruby
193
+ @clock = options[:clock] || MonotonicClock.new # leaf 1: never evaluated, or no gap
194
+ ```
195
+
196
+ ### Methods
197
+
198
+ With `measure :conditions, :paths`, each method with two or more
199
+ decisions also counts each call's path through them: the outcome of each
200
+ decision it reached, in order. A method is named by the `path:line:column`
201
+ of its `def`, then its name. Each decision is one outcome on the path,
202
+ however many leaves its predicate has; those are its site's to show. So a
203
+ site's columns stay its own leaves' combinations, and a method's its own
204
+ decisions', rather than multiplying together. A decision whose arm always
205
+ leaves the method ends the path there.
206
+
207
+ ### Reporters
208
+
209
+ - `:html` writes `index.html` to `coverage_dir`: the covered files with
210
+ their sites, outcomes seen (with a percent) and outcomes missing, each
211
+ column sortable. Each file name opens `files/<path>.html`, the file's
212
+ source. On a line holding a site the line number is a button (blue
213
+ and outlined when every outcome was seen, solid orange when any is
214
+ missing) that opens a grid of the site's paths, on the left. Its rows
215
+ are the site's tree: its operators, nested as Ruby groups them, a
216
+ ternary's arms marked `?` and `:`, an `if`'s named `then` and `else`,
217
+ and its leaves. Its columns are the site's paths, never taken first,
218
+ each headed by its count, orange when zero; a leaf shows its outcome on
219
+ each path, T or F, blank where the path never reached it, and a plain
220
+ arm a dot on each path that took it. A site written over several lines
221
+ has one button over all of its line numbers. Grids with gaps start
222
+ open. A header that stays at the top of the page lists every site as a
223
+ link to its line, covered sites first, then those with gaps, and arrows
224
+ beside each site with gaps jump to the previous and next one, wrapping
225
+ round at either end. Measuring paths, each method's grid of its
226
+ decisions' paths opens on the right.
227
+ - `:post` sends the JSON report, as an HTTP POST, to the url set by
228
+ `collector 'http://...'`. It is for a run whose own files are thrown
229
+ away, such as a failing `docker build` step. It prints nothing, never
230
+ goes through an `http_proxy`, and gives up in silence on a collector
231
+ that cannot be reached or does not answer within a second, so the
232
+ run's output and exit status are unchanged.
233
+
234
+ ## How it works
235
+
236
+ At load time each covered file is read and parsed once, with Prism. The
237
+ parse gives the manifest: each site's condition tree and the byte range of
238
+ each of its leaves. The rewriter then inserts a record call around each
239
+ leaf, and a leave call around the whole site, whose first argument enters
240
+ it, without parsing again, and the result is compiled in place of the
241
+ file:
242
+
243
+ ```ruby
244
+ ok?(id) && new?(id)
245
+ # compiles as
246
+ PathCoverage.leave(PathCoverage.enter("k.rb:2:2", 2), PathCoverage.record("k.rb:2:2", 0, ok?(id)) && PathCoverage.record("k.rb:2:2", 1, new?(id)))
247
+ ```
248
+
249
+ An `if` site's leave call wraps its predicate alone, as its path is
250
+ decided there, so an arm that returns or raises still counts it.
251
+ Measuring paths, each method starts a path at its entry, and each
252
+ decision's predicate is wrapped in a decide call too, outside its site's
253
+ calls.
254
+
255
+ A command call such as `ok? id` (after `and`) is not valid as a bare
256
+ argument, so that leaf alone gets parentheses of its own, and so does a
257
+ whole `and` or `or` inside its leave call. Nothing else does:
258
+ parentheses make a statement, and a statement on a continuation line gives
259
+ that line a line-coverage count of its own, which changes SimpleCov's line
260
+ totals. So a command call, or an `and` or `or`, written over several lines
261
+ can add to SimpleCov's relevant lines; nothing else does.
262
+
263
+ `record` counts whether its value was truthy or falsy, and notes it on the
264
+ site's current path; `enter` starts that path, and `leave` counts it.
265
+ Each returns its value unchanged, so short-circuiting and the value of
266
+ the expression are preserved. Paths are kept per fiber, so threads never
267
+ mix their outcomes; an evaluation cut short by a raise counts no path,
268
+ so no path is ever claimed that was not taken. Only text is inserted,
269
+ never a newline, so backtrace line numbers still match the file. The
270
+ rewrite happens in memory; no file on disk changes.
271
+
272
+ At exit the manifests parsed during the run are reused; only covered files
273
+ the run never loaded are read and parsed then.
274
+
275
+ ## Roadmap
276
+
277
+ - decisions hidden inside calls, each with its own outcome classes:
278
+ - `any?` / `all?` / `none?`: empty, decided by the first item, decided
279
+ later, never decided
280
+ - `find` / `detect`: found vs nil
281
+ - `fetch(k, default)`, `dig`, `Hash#[]` with a default: present vs default
282
+ - `x || default` in value context, `&.` safe navigation: nil vs not nil
283
+ - `||=` and `&&=`, on variables, attributes and index targets: the
284
+ right side evaluated vs skipped, counted without evaluating the
285
+ target twice
286
+ - `x rescue y` modifier: raised vs not raised
287
+ - `unless`, modifier `if`, `case`/`when` and `in` patterns
288
+ - regex matches: nil vs MatchData
289
+
290
+ ## Constraints
291
+
292
+ - CRuby only: the hook is `RubyVM::InstructionSequence.load_iseq`.
293
+ - Ruby 3.2 or later, for `String#bytesplice`.
294
+ - That hook has a single slot, which Bootsnap also uses, so the two cannot
295
+ both be active in one process.
296
+
297
+ ## Development
298
+
299
+ `make all` runs, in a Docker image built from the repo's `Dockerfile`:
300
+
301
+ - `make lint`: rubocop over `lib/`, `test/` and `bin/` alike
302
+ - `make test`: the tests, then a check that the coverage report has zero
303
+ missed lines and branches in both the code and the tests, within the
304
+ bounds in `test/coverage_metrics_params.json`
305
+ (`make test tids='6D1K'` runs only matching tests, and skips the check)
306
+ - `make test_check`: shunit2 tests showing that a breached bound fails the
307
+ check with a non-zero exit
308
+
309
+ `make demo` (not part of `make all`) runs `demo/run.rb` through
310
+ `bin/build_demo.sh`, writing the report to `site/` and opening it. The
311
+ Pages workflow (`.github/workflows/pages.yml`) runs the same script on
312
+ each push to `main` and publishes `site/`.
313
+
314
+ `bin/capture_readme_images.sh` rebuilds the demo and saves this README's
315
+ two screenshots, `docs/decision.png` and `docs/method.png`, from it with
316
+ headless Chrome. Run it again whenever the report's look changes.
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env ruby
2
+ require 'fileutils'
3
+ require 'socket'
4
+ require 'path_coverage/collector'
5
+
6
+ if ARGV.first == '-h'
7
+ puts <<~HELP
8
+ Usage: path_coverage_collector PORT DIR
9
+
10
+ Listens on PORT and saves each JSON report the :post reporter
11
+ sends as DIR/run-0001.json, DIR/run-0002.json, and so on,
12
+ creating DIR if need be. Runs until stopped.
13
+
14
+ Options:
15
+ -h Show this help
16
+
17
+ Example:
18
+ path_coverage_collector 4570 /tmp/condition/runs
19
+ HELP
20
+ exit 0
21
+ end
22
+
23
+ port, dir = ARGV
24
+ FileUtils.mkdir_p(dir)
25
+ server = TCPServer.new('0.0.0.0', Integer(port))
26
+ PathCoverage::Collector.serve(server, dir)
@@ -0,0 +1,5 @@
1
+ require_relative 'kernel'
2
+
3
+ # Required by RUBYOPT=-rpath_coverage/auto, to cover a run
4
+ # with no change to its code, its settings all in ENV.
5
+ PathCoverage.start_from_env(ENV)
@@ -0,0 +1,46 @@
1
+ module PathCoverage
2
+ # Receives the JSON reports the :post reporter sends, so the
3
+ # runs that sent them, eg failing docker build steps, can
4
+ # be merged and rendered once they are all done.
5
+ module Collector
6
+ # Answers every request on server, forever, saving each
7
+ # body in dir as run-0001.json, run-0002.json, and so on,
8
+ # in the order they arrive. A connection sending no
9
+ # request, eg a check that the collector is up, is skipped.
10
+ def self.serve(server, dir)
11
+ n = 0
12
+ loop do
13
+ body = request_body(server.accept)
14
+ next if body.nil?
15
+
16
+ n += 1
17
+ File.write(format('%<dir>s/run-%<n>04d.json', dir:, n:), body)
18
+ end
19
+ end
20
+
21
+ # The body of the one HTTP request on client, after
22
+ # answering it 200; nil, unanswered, when client closes
23
+ # without sending a request.
24
+ def self.request_body(client)
25
+ headers = []
26
+ while (line = client.gets) && line != "\r\n"
27
+ headers << line
28
+ end
29
+ body = headers.empty? ? nil : answer(client, headers)
30
+ client.close
31
+ body
32
+ end
33
+
34
+ # The body of a request with headers, read from client,
35
+ # which is then answered 200.
36
+ def self.answer(client, headers)
37
+ length = headers.sum do |line|
38
+ name, value = line.split(':', 2)
39
+ name.casecmp?('content-length') ? value.to_i : 0
40
+ end
41
+ body = client.read(length)
42
+ client.write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
43
+ body
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,45 @@
1
+ module PathCoverage
2
+ # The operators whose predicate picks an arm.
3
+ CHOICES = %w[?: if].freeze
4
+
5
+ # One arm of a ternary or an if: kind is :then or :else,
6
+ # extent its byte range, nil for an if's arm with no
7
+ # statements, and body the Condition it is, when it is one,
8
+ # else nil for a plain value or statements, which decide
9
+ # nothing.
10
+ Arm = Data.define(:kind, :extent, :body)
11
+
12
+ # One site's condition as the manifest found it: its operator
13
+ # and its operands, each the byte range of a leaf or a nested
14
+ # Condition. A site's commands are the byte ranges of its
15
+ # leaves that are command calls, eg `ok? id`; its fixed are
16
+ # { byte range => :truthy } for leaves that can have that one
17
+ # outcome only, eg `Clock.new`. Its extent is the byte range
18
+ # of the whole condition as written, eg a ternary's from its
19
+ # predicate to its else arm, which its leaves alone cannot
20
+ # give.
21
+ Condition = Data.define(
22
+ :operator, :operands, :commands, :fixed, :extent
23
+ ) do
24
+ # A condition with no command-call or fixed leaves, and no
25
+ # extent, by default.
26
+ def initialize(
27
+ operator:, operands:, commands: [], fixed: {}, extent: nil
28
+ )
29
+ super
30
+ end
31
+
32
+ # The byte range of every leaf operand, left to right, a
33
+ # ternary's arms' after its predicate's; a leaf's index here
34
+ # is the index it is recorded under.
35
+ def leaves
36
+ operands.flat_map do |operand|
37
+ case operand
38
+ when Condition then operand.leaves
39
+ when Arm then operand.body&.leaves || []
40
+ else [operand]
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,127 @@
1
+ module PathCoverage
2
+ # Raised for a start block that cannot be applied.
3
+ class ConfigurationError < StandardError; end
4
+
5
+ # The settings given in a PathCoverage.start block.
6
+ class Configuration
7
+ GLOB_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
8
+ SETTINGS = %w[
9
+ collector cover coverage_dir measure report root
10
+ ].freeze
11
+ REPORTERS = %w[gaps html post].freeze
12
+ MEASURES = %w[conditions paths].freeze
13
+
14
+ # Evaluates the settings block against a new configuration.
15
+ def initialize(&)
16
+ @globs = []
17
+ @reporters = [:gaps]
18
+ @measures = [:conditions]
19
+ instance_eval(&)
20
+ end
21
+
22
+ # The reporters chosen with report, :gaps when none were.
23
+ attr_reader :reporters
24
+
25
+ # What is measured, chosen with measure, :conditions when
26
+ # nothing was.
27
+ attr_reader :measures
28
+
29
+ # Chooses what is measured, eg measure :conditions, :paths;
30
+ # refuses a name there is no measure for.
31
+ def measure(*names)
32
+ check('measure', names, MEASURES)
33
+ @measures = names.map(&:to_sym)
34
+ end
35
+
36
+ # The dir the reports are written to.
37
+ def output_dir
38
+ @coverage_dir
39
+ end
40
+
41
+ # The url the :post reporter sends the JSON report to.
42
+ attr_reader :collector_url
43
+
44
+ # Sets the url the :post reporter sends the JSON report to.
45
+ def collector(url)
46
+ @collector_url = url
47
+ end
48
+
49
+ # Sets the dir that cover globs and site names are
50
+ # relative to.
51
+ def root(dir)
52
+ @root = dir
53
+ end
54
+
55
+ # Adds a glob, relative to root, of files to instrument.
56
+ def cover(glob)
57
+ @globs << glob
58
+ end
59
+
60
+ # Chooses the reporters run at exit, eg report :gaps;
61
+ # refuses a name there is no reporter for.
62
+ def report(*names)
63
+ check('reporter', names, REPORTERS)
64
+ @reporters = names.map(&:to_sym)
65
+ end
66
+
67
+ # Refuses the first of names that is not in known, naming
68
+ # it as a kind and listing the known ones.
69
+ def check(kind, names, known)
70
+ names.each do |name|
71
+ next if known.include?(name.to_s)
72
+
73
+ raise ConfigurationError, [
74
+ "unknown #{kind} #{name} in PathCoverage.start",
75
+ "the #{kind}s are: #{known.join(', ')}"
76
+ ].join("\n")
77
+ end
78
+ end
79
+
80
+ # Sets the dir the path_coverage.json report is
81
+ # written to.
82
+ def coverage_dir(dir)
83
+ @coverage_dir = dir
84
+ end
85
+
86
+ # Every file the cover globs match under root, as full
87
+ # paths, whether or not the run loads it.
88
+ def files
89
+ Dir.glob(@globs, base: @root).sort.map { |f| "#{@root}/#{f}" }
90
+ end
91
+
92
+ # Where the JSON report goes.
93
+ def json_path
94
+ "#{output_dir}/path_coverage.json"
95
+ end
96
+
97
+ # True when path is under root and matches a cover glob.
98
+ def covers?(path)
99
+ path.start_with?("#{@root}/") && matches?(relative(path))
100
+ end
101
+
102
+ # True when the root-relative path matches a cover glob.
103
+ def matches?(relative_path)
104
+ @globs.any? do |glob|
105
+ File.fnmatch?(glob, relative_path, GLOB_FLAGS)
106
+ end
107
+ end
108
+
109
+ # path relative to root, eg "source/kata.rb".
110
+ def relative(path)
111
+ path.delete_prefix("#{@root}/")
112
+ end
113
+
114
+ # Refuses a setting that does not exist, naming it.
115
+ def method_missing(name, *)
116
+ raise ConfigurationError, [
117
+ "unknown setting #{name} in PathCoverage.start",
118
+ "the settings are: #{SETTINGS.join(', ')}"
119
+ ].join("\n")
120
+ end
121
+
122
+ # Answers only for the settings there are.
123
+ def respond_to_missing?(_name, _include_private = false)
124
+ false
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,60 @@
1
+ module PathCoverage
2
+ # The terminal worklist: one line per path a site never
3
+ # took, and nothing for a site that took them all. Every
4
+ # count stays in the operands and paths for reporters that
5
+ # show them all.
6
+ module Gaps
7
+ # The gap lines of operands and paths, as Result gives
8
+ # them, in manifest order; texts is { site => each leaf's
9
+ # source text }.
10
+ def self.lines(operands, paths: {}, texts: {})
11
+ operands.flat_map do |site, counts|
12
+ if counts.all? { |each| never_ran?(each) }
13
+ next ["#{site} never reached"]
14
+ end
15
+
16
+ path_gaps(site, paths.fetch(site, []), texts[site])
17
+ end
18
+ end
19
+
20
+ # One line per path site never took, as Result#paths gives
21
+ # them, naming the outcome of each leaf the path reaches,
22
+ # texts being each leaf's source text.
23
+ def self.path_gaps(site, paths, texts)
24
+ paths.select { |each| each[:count].zero? }.map do |each|
25
+ steps = each[:path].zip(texts).filter_map do |outcome, text|
26
+ "#{code(text)} #{outcome}" unless outcome.nil?
27
+ end
28
+ "#{site} path never taken: #{steps.join(', ')}"
29
+ end
30
+ end
31
+
32
+ # text as a markdown code span on one line: each run of
33
+ # whitespace one space, fenced by a run of backticks one
34
+ # longer than any inside it, with a space inside the fence
35
+ # when text starts or ends with one.
36
+ def self.code(text)
37
+ text = text.gsub(/\s+/, ' ')
38
+ longest = text.scan(/`+/).map(&:size).max || 0
39
+ fence = '`' * (longest + 1)
40
+ pad = text.start_with?('`') || text.end_with?('`') ? ' ' : ''
41
+ "#{fence}#{pad}#{text}#{pad}#{fence}"
42
+ end
43
+
44
+ # The one outcome this operand is missing, if any; a fixed
45
+ # leaf, which can have one outcome only, needs only to run.
46
+ def self.operand_gap(counts)
47
+ return ['never evaluated'] if never_ran?(counts)
48
+ return [] if counts[:only]
49
+ return ['never true'] if counts[:truthy].zero?
50
+ return ['never false'] if counts[:falsy].zero?
51
+
52
+ []
53
+ end
54
+
55
+ # True when the operand was never recorded either way.
56
+ def self.never_ran?(counts)
57
+ counts[:truthy].zero? && counts[:falsy].zero?
58
+ end
59
+ end
60
+ end