simplecov-ai 0.10.6 → 0.11.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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/README.md +216 -45
- data/lib/simplecov-ai/ast_resolver/bypass_scanner.rb +69 -0
- data/lib/simplecov-ai/ast_resolver/dynamic_method_resolver.rb +61 -0
- data/lib/simplecov-ai/ast_resolver/lenient_literals.rb +26 -0
- data/lib/simplecov-ai/ast_resolver/metaclass_resolver.rb +71 -0
- data/lib/simplecov-ai/ast_resolver/node_children.rb +56 -0
- data/lib/simplecov-ai/ast_resolver/node_classifier.rb +158 -0
- data/lib/simplecov-ai/ast_resolver/parser_backend.rb +174 -0
- data/lib/simplecov-ai/ast_resolver/receiver_resolver.rb +61 -0
- data/lib/simplecov-ai/ast_resolver/semantic_node.rb +47 -16
- data/lib/simplecov-ai/ast_resolver.rb +45 -115
- data/lib/simplecov-ai/configuration.rb +101 -22
- data/lib/simplecov-ai/constants.rb +0 -3
- data/lib/simplecov-ai/line_span.rb +37 -0
- data/lib/simplecov-ai/markdown_builder/branch_enricher.rb +81 -38
- data/lib/simplecov-ai/markdown_builder/bypass_compiler.rb +41 -43
- data/lib/simplecov-ai/markdown_builder/decode_guard.rb +58 -0
- data/lib/simplecov-ai/markdown_builder/deficit_compiler.rb +61 -63
- data/lib/simplecov-ai/markdown_builder/deficit_formatter.rb +100 -86
- data/lib/simplecov-ai/markdown_builder/deficit_group.rb +84 -0
- data/lib/simplecov-ai/markdown_builder/deficit_grouper.rb +85 -42
- data/lib/simplecov-ai/markdown_builder/inline_code.rb +33 -0
- data/lib/simplecov-ai/markdown_builder/report_budget.rb +88 -0
- data/lib/simplecov-ai/markdown_builder/section_writer.rb +86 -0
- data/lib/simplecov-ai/markdown_builder/skip_regions.rb +128 -0
- data/lib/simplecov-ai/markdown_builder/snippet_formatter.rb +117 -18
- data/lib/simplecov-ai/markdown_builder/source_lines.rb +28 -0
- data/lib/simplecov-ai/markdown_builder.rb +107 -74
- data/lib/simplecov-ai/version.rb +1 -1
- data/lib/simplecov-ai.rb +69 -12
- data.tar.gz.sig +0 -0
- metadata +88 -71
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c74a6bfde86ee9beeaf6a02adb04c043878e1c2d7c6e4fc173a00a9d479998de
|
|
4
|
+
data.tar.gz: 98dc5d09ad61af95a6ffd131ee5b96a07fbda217000bb1e4af032d8024ba2fd8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6e19f160818648eafc77ce7865d47df9e439a7de3004de11b483d3eefd2e458c025fa94ee0ad8615e062c7898012e1e66072510506a15a128164c1a6fa172db2
|
|
7
|
+
data.tar.gz: 0e0ec8706c0a2545047ed541a783566cc802a32776f84eb45ad5a80afa65fac7508d9007224ada990f536ce4bce05d008ce9e667c048fdd8b42a370278d7dd0f
|
checksums.yaml.gz.sig
CHANGED
|
Binary file
|
data/README.md
CHANGED
|
@@ -1,19 +1,45 @@
|
|
|
1
1
|
# simplecov-ai
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A `SimpleCov::Formatter` that writes a compact Markdown digest of missing coverage for Large
|
|
4
|
+
Language Models (LLMs) and autonomous engineering agents.
|
|
4
5
|
|
|
5
|
-
Standard coverage reporters
|
|
6
|
+
Standard coverage reporters produce large HTML files or exhaustive JSON/console output keyed by
|
|
7
|
+
line numbers, which fills an LLM's context window with coordinates that shift on every edit.
|
|
8
|
+
`simplecov-ai` resolves each missed line and branch to its enclosing module, class or method
|
|
9
|
+
through the Ruby AST and emits only those semantic groups, each with the exact unexecuted
|
|
10
|
+
expression.
|
|
6
11
|
|
|
7
|
-
##
|
|
12
|
+
## What the digest contains
|
|
8
13
|
|
|
9
|
-
- **Semantic
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
- **Semantic resolution.** Deficits are grouped under the innermost enclosing node:
|
|
15
|
+
`Module::Class#method` for instance methods, `Module::Class.method` for singleton methods
|
|
16
|
+
(`def self.x`, `class << self`), constants bound to `Struct.new`, `Class.new`, `Module.new` or
|
|
17
|
+
`Data.define` blocks (`Point#distance`), `define_method` / `define_singleton_method` blocks with
|
|
18
|
+
a literal name, and `class << obj` singleton classes opened on a variable or constant
|
|
19
|
+
(`obj.name`, `@ivar.name`, `Foo::Bar.name`). A method defined at the top level is `Object#name`
|
|
20
|
+
(Ruby adds it to `Object`, which is how SimpleCov's method coverage names it too) and a top-level
|
|
21
|
+
`def self.x` is `main.x`; any other code outside a class or method belongs to `main`, the root
|
|
22
|
+
scope of the file.
|
|
23
|
+
- **Exact snippets.** Each deficit carries its line(s) as `[L<n>]` or `[L<n>-<m>]` and the exact
|
|
24
|
+
source text: a missed branch quotes only its own arm (the `:neg` of `x.positive? ? :pos : :neg`),
|
|
25
|
+
the `else` arm that spans an `elsif` chain is cut to its first line plus `...`, and identical
|
|
26
|
+
lines within one node are told apart with `(Occurrence N of M)`.
|
|
27
|
+
- **Token conservation.** Fully covered files are omitted, the `## Coverage Deficits` section
|
|
28
|
+
disappears on a perfect run, and `max_file_size_kb` is a hard ceiling on the written file.
|
|
29
|
+
- **Bypass audit.** Every region SimpleCov skipped (`# :nocov:`, `# simplecov:disable`) is listed
|
|
30
|
+
with the directive that caused it, so artificially inflated metrics stay visible to the reader.
|
|
31
|
+
- **Method coverage.** With SimpleCov >= 1.0 and `enable_coverage :method`, never-invoked methods
|
|
32
|
+
are reported too.
|
|
13
33
|
|
|
14
|
-
##
|
|
34
|
+
## Requirements
|
|
35
|
+
|
|
36
|
+
- **Ruby** 2.7 through 4.0 (MRI). JRuby and TruffleRuby run the formatter but implement no branch
|
|
37
|
+
coverage, so only line deficits are reported there and the header shows `N/A` for branches.
|
|
38
|
+
- **SimpleCov** `>= 0.18, < 2.0`. Branch coverage needs `enable_coverage :branch`. Method
|
|
39
|
+
coverage and `# simplecov:disable` directives are SimpleCov 1.x features; on older releases they
|
|
40
|
+
are neither measured nor reported.
|
|
15
41
|
|
|
16
|
-
|
|
42
|
+
## Installation
|
|
17
43
|
|
|
18
44
|
```ruby
|
|
19
45
|
group :test do
|
|
@@ -22,67 +48,212 @@ group :test do
|
|
|
22
48
|
end
|
|
23
49
|
```
|
|
24
50
|
|
|
25
|
-
## Usage
|
|
51
|
+
## Usage
|
|
26
52
|
|
|
27
|
-
Require
|
|
53
|
+
Require the formatter after `simplecov` in your test helper and register it with SimpleCov:
|
|
28
54
|
|
|
29
55
|
```ruby
|
|
30
56
|
require 'simplecov'
|
|
31
57
|
require 'simplecov-ai'
|
|
32
58
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
config.max_file_size_kb = 50 # Maximum size (Token Ceiling)
|
|
37
|
-
config.max_snippet_lines = 5 # AST context truncation limit
|
|
38
|
-
config.output_to_console = false # Echo digest to STDOUT
|
|
39
|
-
config.granularity = :fine # :fine (statements) or :coarse (methods)
|
|
40
|
-
config.include_bypasses = true # Audit `:nocov:` ignores
|
|
59
|
+
SimpleCov.start do
|
|
60
|
+
enable_coverage :branch
|
|
61
|
+
skip '/spec/' # SimpleCov >= 1.0; use `add_filter '/spec/'` on 0.x (1.x deprecates it)
|
|
41
62
|
end
|
|
42
63
|
|
|
43
|
-
SimpleCov.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
64
|
+
SimpleCov.formatter = SimpleCov::Formatter::AIFormatter
|
|
65
|
+
# Alongside other formatters:
|
|
66
|
+
# SimpleCov.formatters = [SimpleCov::Formatter::HTMLFormatter, SimpleCov::Formatter::AIFormatter]
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
When the suite exits, the formatter writes the report and announces it on STDOUT:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
AI coverage digest written to /path/to/project/coverage/ai_report.md
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Configuration
|
|
76
|
+
|
|
77
|
+
All settings are optional; the defaults are shown:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
SimpleCov::Formatter::AIFormatter.configure do |config|
|
|
81
|
+
config.report_path = 'coverage/ai_report.md' # default: ai_report.md inside SimpleCov's coverage_dir
|
|
82
|
+
config.max_file_size_kb = 50 # hard ceiling on the written file (metric kB)
|
|
83
|
+
config.max_snippet_lines = 5 # snippets longer than 5 x 80 characters end in `...`
|
|
84
|
+
config.output_to_console = false # true: print the digest to STDOUT instead of the notice
|
|
85
|
+
config.granularity = :fine # :fine (every line/branch) or :coarse (one line per node)
|
|
86
|
+
config.include_bypasses = true # false: omit the "Ignored Coverage Bypasses" section
|
|
49
87
|
end
|
|
50
88
|
```
|
|
51
89
|
|
|
52
|
-
|
|
90
|
+
- `report_path` — unset, the digest goes to `ai_report.md` inside `SimpleCov.coverage_path`, so
|
|
91
|
+
a custom `coverage_dir` is honoured (the reader reports this default as
|
|
92
|
+
`coverage/ai_report.md`). An explicit absolute path is used as-is; an explicit relative path is
|
|
93
|
+
resolved against `SimpleCov.root`, independent of the working directory at exit. Blank values
|
|
94
|
+
and values containing a NUL byte are rejected.
|
|
95
|
+
- `max_file_size_kb`, `max_snippet_lines` — positive Integers.
|
|
96
|
+
- `granularity` — `:fine` or `:coarse`.
|
|
97
|
+
- `output_to_console`, `include_bypasses` — `true` or `false`.
|
|
98
|
+
|
|
99
|
+
Every setting is validated when it is assigned: a value of the wrong type raises `TypeError`
|
|
100
|
+
(the writers are typed with sorbet-runtime) and an out-of-range value raises `ArgumentError`
|
|
101
|
+
naming the setting, for example `granularity must be one of [:fine, :coarse], got :medium`.
|
|
102
|
+
`SimpleCov::Formatter::AIFormatter.reset_configuration!` discards the configuration so the next
|
|
103
|
+
access starts from the defaults (useful in test suites).
|
|
104
|
+
|
|
105
|
+
## Example output
|
|
53
106
|
|
|
54
|
-
|
|
107
|
+
Generated from a small sample project on Ruby 4.0 and SimpleCov 1.1.1, trimmed by one file and
|
|
108
|
+
one node that quotes a 300-character line:
|
|
55
109
|
|
|
56
110
|
```md
|
|
57
111
|
# AI Coverage Digest
|
|
58
112
|
**Status:** FAILED
|
|
59
|
-
**Global Line Coverage:**
|
|
60
|
-
**Global Branch Coverage:**
|
|
61
|
-
**Generated At:** 2026-
|
|
62
|
-
**Report File Size:** 1.2 kB
|
|
63
|
-
|
|
113
|
+
**Global Line Coverage:** 69.3%
|
|
114
|
+
**Global Branch Coverage:** 38.4%
|
|
115
|
+
**Generated At:** 2026-08-28T06:05:29+00:00 (Local Timezone)
|
|
64
116
|
## Coverage Deficits
|
|
65
117
|
|
|
66
|
-
### `lib/
|
|
67
|
-
- `
|
|
68
|
-
- **
|
|
69
|
-
- `
|
|
70
|
-
- **Line Deficit:**
|
|
118
|
+
### `lib/sample/weird.rb`
|
|
119
|
+
- `Sample::Weird#dupes`
|
|
120
|
+
- **Line Deficit:** [L11] `a += 1` (Occurrence 1 of 3).
|
|
121
|
+
- **Line Deficit:** [L12] `a += 1` (Occurrence 2 of 3).
|
|
122
|
+
- **Line Deficit:** [L13] `a += 1` (Occurrence 3 of 3).
|
|
123
|
+
- **Line Deficit:** [L14] `a`
|
|
124
|
+
|
|
125
|
+
### `lib/sample/calc.rb`
|
|
126
|
+
- `Sample::Calc#sign`
|
|
127
|
+
- **Branch Deficit:** [L7] Missing coverage for `else` branch: `:neg`
|
|
128
|
+
- `Sample::Calc#classify`
|
|
129
|
+
- **Line Deficit:** [L13] `elsif n.odd?`
|
|
130
|
+
- **Line Deficit:** [L14] `:odd`
|
|
131
|
+
- **Line Deficit:** [L16] `:even`
|
|
132
|
+
- **Branch Deficit:** [L13-17] Missing coverage for `else` branch: `elsif n.odd?...`
|
|
133
|
+
- **Branch Deficit:** [L14] Missing coverage for `then` branch: `:odd`
|
|
134
|
+
- **Branch Deficit:** [L16] Missing coverage for `else` branch: `:even`
|
|
135
|
+
- `Sample::Calc#bucket`
|
|
136
|
+
- **Line Deficit:** [L23] `when 2 then :two`
|
|
137
|
+
- **Line Deficit:** [L24] `else :many`
|
|
138
|
+
- **Branch Deficit:** [L23] Missing coverage for `when` branch: `:two`
|
|
139
|
+
- **Branch Deficit:** [L24] Missing coverage for `else` branch: `:many`
|
|
140
|
+
- `Sample::Calc#never_called`
|
|
141
|
+
- **Line Deficit:** [L29] `@never = 1`
|
|
142
|
+
- **Line Deficit:** [L30] `@never += 1`
|
|
143
|
+
- `Sample::Calc.unused_factory`
|
|
144
|
+
- **Line Deficit:** [L55] `new.tap { |c| c.sign(1) }`
|
|
145
|
+
- `Sample::Point#origin?`
|
|
146
|
+
- **Line Deficit:** [L66] `x.zero? && y.zero?`
|
|
71
147
|
|
|
72
|
-
### `lib/
|
|
73
|
-
- `
|
|
74
|
-
- **Branch Deficit:** Missing coverage for
|
|
148
|
+
### `lib/sample/boot.rb`
|
|
149
|
+
- `main`
|
|
150
|
+
- **Branch Deficit:** [L9] Missing coverage for `then` branch: `true`
|
|
75
151
|
|
|
76
152
|
## Ignored Coverage Bypasses
|
|
77
153
|
|
|
78
|
-
### `lib/
|
|
79
|
-
- `
|
|
80
|
-
- **Bypass Present:**
|
|
154
|
+
### `lib/sample/boot.rb`
|
|
155
|
+
- `main`
|
|
156
|
+
- **Bypass Present:** Coverage explicitly ignored via `# :nocov:`.
|
|
157
|
+
|
|
158
|
+
### `lib/sample/calc.rb`
|
|
159
|
+
- `Sample::Calc#legacy_skipped`
|
|
160
|
+
- **Bypass Present:** Coverage explicitly ignored via `# :nocov:`.
|
|
161
|
+
- `Sample::Calc#inline_disabled`
|
|
162
|
+
- **Bypass Present:** Coverage explicitly ignored via `# simplecov:disable`.
|
|
163
|
+
- `Sample::Calc#branch_scoped`
|
|
164
|
+
- **Bypass Present:** Coverage explicitly ignored via `# simplecov:disable branch`.
|
|
81
165
|
```
|
|
82
166
|
|
|
83
|
-
|
|
167
|
+
Things to notice:
|
|
168
|
+
|
|
169
|
+
- Files are ordered by coverage, lowest first (path as the tie-break); nodes appear in source
|
|
170
|
+
order.
|
|
171
|
+
- `main` is the root scope of `lib/sample/boot.rb`: the missed `then` arm belongs to a top-level
|
|
172
|
+
statement (`SAMPLE_DEBUG = ENV['SAMPLE_DEBUG'] ? true : false`), and the `# :nocov:` region
|
|
173
|
+
wraps only top-level code.
|
|
174
|
+
- `[L13-17] … elsif n.odd?...` is the `else` arm of the outer `if` in `classify`; it spans the
|
|
175
|
+
whole `elsif` chain, so it is cut to its first line instead of repeating the inner arms.
|
|
176
|
+
- The `Status` is `PASSED` only when every measured criterion is at 100%, and percentages are
|
|
177
|
+
floored to one decimal, so a run at 99.96% reads `99.9%`.
|
|
178
|
+
|
|
179
|
+
### Method coverage (SimpleCov >= 1.0)
|
|
180
|
+
|
|
181
|
+
With `enable_coverage :method` the header gains a method line, the status accounts for it, and
|
|
182
|
+
each never-invoked method is listed under its node before its line and branch deficits. From the
|
|
183
|
+
same sample:
|
|
184
|
+
|
|
185
|
+
```md
|
|
186
|
+
**Global Method Coverage:** 53.3%
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
```md
|
|
190
|
+
- `Sample::Calc#never_called`
|
|
191
|
+
- **Method Deficit:** [L28-31] `Sample::Calc#never_called` never invoked
|
|
192
|
+
- **Line Deficit:** [L29] `@never = 1`
|
|
193
|
+
- **Line Deficit:** [L30] `@never += 1`
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Bypass audit
|
|
197
|
+
|
|
198
|
+
The `## Ignored Coverage Bypasses` section lists what SimpleCov actually skipped, attributed to
|
|
199
|
+
the outermost nodes a skipped region contains (or to the node enclosing it, `main` at worst), with
|
|
200
|
+
the directive comment quoted verbatim as the reason. Because it is derived from SimpleCov's own
|
|
201
|
+
skip verdicts rather than from a second scan of the comments:
|
|
202
|
+
|
|
203
|
+
- `# :nocov:` pairs (including a custom `nocov_token`), inline `# simplecov:disable` comments and
|
|
204
|
+
`# simplecov:disable line` / `# simplecov:disable branch` regions are all reported;
|
|
205
|
+
- a directive inside a heredoc, which SimpleCov ignores, is not reported;
|
|
206
|
+
- a skipped region made only of comments and blank lines is not reported either, since it excludes
|
|
207
|
+
nothing from any figure — SimpleCov 1.x honours a directive wherever it appears in a comment, so
|
|
208
|
+
a comment that merely quotes `# simplecov:disable` skips its own line;
|
|
209
|
+
- on SimpleCov < 1.0, which does not implement `# simplecov:disable`, those lines stay ordinary
|
|
210
|
+
deficits and only `# :nocov:` regions appear. The same sample on SimpleCov 0.22.0 lists
|
|
211
|
+
`raise 'unreachable' # simplecov:disable` as a line deficit and a single bypass.
|
|
212
|
+
|
|
213
|
+
SimpleCov 1.x itself deprecates `# :nocov:` in favour of `# simplecov:disable` /
|
|
214
|
+
`# simplecov:enable`; both are audited.
|
|
215
|
+
|
|
216
|
+
### Size ceiling
|
|
217
|
+
|
|
218
|
+
`max_file_size_kb` bounds the written file. Both sections are filled lowest-coverage file first,
|
|
219
|
+
one semantic node at a time, until the next node would no longer fit; a single notice then closes
|
|
220
|
+
the report. With a 1 kB limit the sample above ends in:
|
|
221
|
+
|
|
222
|
+
```md
|
|
223
|
+
> **[WARNING] TRUNCATION NOTIFICATION:**
|
|
224
|
+
> The report reached the maximum token constraint (1 kB) and was truncated: 3 deficit file(s) and 2 bypass file(s) omitted or cut short. Lowest-coverage files are listed first; resolve the deficits above to reveal the remaining ones in subsequent test runs.
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
A file whose block was cut short counts towards those numbers. No notice is printed when
|
|
228
|
+
everything fits.
|
|
229
|
+
|
|
230
|
+
## Parser backend
|
|
231
|
+
|
|
232
|
+
Sources are parsed with Prism's `parser`-compatible translation on Ruby >= 3.3 when Prism >= 1.2
|
|
233
|
+
and `parser` >= 3.3.7.2 are installed (the exact grammar of the running Ruby, about twice as fast
|
|
234
|
+
as the `parser` gem); otherwise the `parser` gem's grammar for the running Ruby is used, with
|
|
235
|
+
`parser/current` as a muted last resort. Ruby 2.7 to 3.2 always use the `parser` gem. The
|
|
236
|
+
selection happens once at load time, and no parser diagnostic — including the
|
|
237
|
+
`parser/current is loading …` version warning — is ever written to STDERR.
|
|
238
|
+
|
|
239
|
+
## Error handling
|
|
240
|
+
|
|
241
|
+
Reporting is best-effort and never aborts a passing test run. A file the parser cannot process is
|
|
242
|
+
listed with its raw line numbers under an `AST Parsing Failed` notice while the other files are
|
|
243
|
+
resolved normally. Sources are read as bytes and decoded per their `# encoding:` magic comment or
|
|
244
|
+
byte-order mark, string literals whose escapes are invalid in UTF-8 (`"\xf0-\xff"`) are accepted
|
|
245
|
+
as MRI accepts them, and a file that cannot be read at all is reported without snippets (each
|
|
246
|
+
shown as a code span holding one space). Branch column data that a SimpleCov version does not
|
|
247
|
+
provide degrades to full-line snippets. Snippets, names, paths and directive comments are rendered
|
|
248
|
+
as code spans that stay intact whatever characters they contain (see `SECURITY.md`). If the
|
|
249
|
+
report itself cannot be written — a `report_path` whose parent is a regular file, a read-only or
|
|
250
|
+
full disk — the formatter prints `AI coverage digest could not be written to <path> (<error>)` on
|
|
251
|
+
STDERR, still echoes the digest when `output_to_console` is set, and does not raise from
|
|
252
|
+
SimpleCov's `at_exit` hook.
|
|
84
253
|
|
|
85
|
-
|
|
254
|
+
One limitation: nodes are resolved by line, so two definitions on one line
|
|
255
|
+
(`def a; 1; end; def b; 2; end`) both resolve to the line's last node; a method deficit keeps the
|
|
256
|
+
name SimpleCov derived unless that node carries the method's own name.
|
|
86
257
|
|
|
87
258
|
## License
|
|
88
259
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module SimpleCov
|
|
5
|
+
module Formatter
|
|
6
|
+
class AIFormatter
|
|
7
|
+
class ASTResolver
|
|
8
|
+
# Attributes coverage-skip regions (line ranges SimpleCov excluded from its metrics,
|
|
9
|
+
# each paired with the directive text that caused the exclusion) to the semantic nodes
|
|
10
|
+
# they cover. A region is attributed to the outermost semantic nodes it fully contains,
|
|
11
|
+
# or — when it sits inside a single node — to that innermost enclosing node; a region
|
|
12
|
+
# wrapping only top-level code therefore lands on the root scope.
|
|
13
|
+
module BypassScanner
|
|
14
|
+
extend T::Sig
|
|
15
|
+
|
|
16
|
+
# A skipped line range together with the reason SimpleCov skipped it.
|
|
17
|
+
Region = T.type_alias { [T::Range[Integer], String] }
|
|
18
|
+
# Reasons keyed by the node they apply to.
|
|
19
|
+
ReasonsByNode = T.type_alias { T::Hash[SemanticNode, T::Array[String]] }
|
|
20
|
+
|
|
21
|
+
# Attributes every region to the matching semantic nodes. The result is a pure
|
|
22
|
+
# function of its inputs: nodes are never mutated, and a reason is listed once per
|
|
23
|
+
# node even when several regions inside that node carry the same directive text.
|
|
24
|
+
#
|
|
25
|
+
# @param nodes [Array<SemanticNode>] The resolved structural entities in pre-order.
|
|
26
|
+
# @param regions [Array<Array(Range<Integer>, String)>] Skipped ranges with reasons.
|
|
27
|
+
# @return [Hash{SemanticNode => Array<String>}] The reasons attributed to each node,
|
|
28
|
+
# keyed by node identity; nodes without a bypass are absent.
|
|
29
|
+
sig { params(nodes: T::Array[SemanticNode], regions: T::Array[Region]).returns(ReasonsByNode) }
|
|
30
|
+
def self.attribute(nodes, regions)
|
|
31
|
+
reasons_by_node = T.let({}.compare_by_identity, ReasonsByNode)
|
|
32
|
+
regions.each do |range, reason|
|
|
33
|
+
targets_of(nodes, range).each do |node|
|
|
34
|
+
node_reasons = (reasons_by_node[node] ||= [])
|
|
35
|
+
node_reasons << reason unless node_reasons.include?(reason)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
reasons_by_node
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
sig { params(nodes: T::Array[SemanticNode], range: T::Range[Integer]).returns(T::Array[SemanticNode]) }
|
|
42
|
+
def self.targets_of(nodes, range)
|
|
43
|
+
contained = nodes.select { |node| LineSpan.encloses?(range, node.line_range) }
|
|
44
|
+
return outermost(contained) if contained.any?
|
|
45
|
+
|
|
46
|
+
[innermost_enclosing(nodes, range)].compact
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
sig { params(contained: T::Array[SemanticNode]).returns(T::Array[SemanticNode]) }
|
|
50
|
+
def self.outermost(contained)
|
|
51
|
+
contained.reject do |node|
|
|
52
|
+
contained.any? { |other| LineSpan.strictly_encloses?(other.line_range, node.line_range) }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Nodes are in pre-order, so among the (nested) nodes enclosing a region the last one
|
|
57
|
+
# is the innermost — including when an inner node spans exactly the same lines as its
|
|
58
|
+
# parent (a method filling its class, or a class filling the root scope).
|
|
59
|
+
sig { params(nodes: T::Array[SemanticNode], range: T::Range[Integer]).returns(T.nilable(SemanticNode)) }
|
|
60
|
+
def self.innermost_enclosing(nodes, range)
|
|
61
|
+
nodes.reverse.find { |node| LineSpan.encloses?(node.line_range, range) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private_class_method :targets_of, :outermost, :innermost_enclosing
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module SimpleCov
|
|
5
|
+
module Formatter
|
|
6
|
+
class AIFormatter
|
|
7
|
+
class ASTResolver
|
|
8
|
+
# Recognizes `define_method(:name) do … end` and `define_singleton_method(:name) { … }`
|
|
9
|
+
# blocks whose method name is a literal symbol or string. Such a block is the body of the
|
|
10
|
+
# method it defines, so deficits inside it belong to that method rather than to the whole
|
|
11
|
+
# enclosing class. Dynamic names (`define_method(name)`), explicit foreign receivers
|
|
12
|
+
# (`klass.define_method`) and block-less forms (`define_method(:a, method(:b))`) are not
|
|
13
|
+
# recognized and stay transparent.
|
|
14
|
+
module DynamicMethodResolver
|
|
15
|
+
extend T::Sig
|
|
16
|
+
|
|
17
|
+
# Method-defining calls mapped to whether they define a singleton method.
|
|
18
|
+
DEFINERS = T.let({ define_method: false, define_singleton_method: true }.freeze,
|
|
19
|
+
T::Hash[Symbol, T::Boolean])
|
|
20
|
+
# Node types accepted as a literal method name.
|
|
21
|
+
LITERAL_NAME_TYPES = T.let(%i[sym str].freeze, T::Array[Symbol])
|
|
22
|
+
|
|
23
|
+
# @param node [Parser::AST::Node] A node whose type is one of
|
|
24
|
+
# {MetaclassResolver::BLOCK_TYPES}.
|
|
25
|
+
# @return [[String, Boolean], nil] The literal method name and whether the call
|
|
26
|
+
# defines a singleton method, or nil when the block is not a recognized definition.
|
|
27
|
+
sig { params(node: Parser::AST::Node).returns(T.nilable([String, T::Boolean])) }
|
|
28
|
+
def self.definition(node)
|
|
29
|
+
call = MetaclassResolver.block_call(node)
|
|
30
|
+
return nil unless call && implicit_receiver?(call)
|
|
31
|
+
|
|
32
|
+
singleton_definer = DEFINERS[NodeChildren.symbol_at(call, 1)]
|
|
33
|
+
name = literal_name(call)
|
|
34
|
+
return nil if singleton_definer.nil? || name.nil?
|
|
35
|
+
|
|
36
|
+
[name, singleton_definer]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# True when the call has no receiver or `self` as its receiver, i.e. it defines the
|
|
40
|
+
# method on the lexical class being resolved.
|
|
41
|
+
sig { params(call: Parser::AST::Node).returns(T::Boolean) }
|
|
42
|
+
def self.implicit_receiver?(call)
|
|
43
|
+
receiver = NodeChildren.node_at(call, 0)
|
|
44
|
+
receiver.nil? || receiver.type == :self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The first argument of the call as a method name, when it is a symbol or string literal.
|
|
48
|
+
sig { params(call: Parser::AST::Node).returns(T.nilable(String)) }
|
|
49
|
+
def self.literal_name(call)
|
|
50
|
+
argument = NodeChildren.node_at(call, 2)
|
|
51
|
+
return nil unless argument && LITERAL_NAME_TYPES.include?(argument.type)
|
|
52
|
+
|
|
53
|
+
NodeChildren.literal_value(argument)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private_class_method :implicit_receiver?, :literal_name
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module SimpleCov
|
|
5
|
+
module Formatter
|
|
6
|
+
class AIFormatter
|
|
7
|
+
class ASTResolver
|
|
8
|
+
# Extended onto a `parser` builder so it accepts string literals whose escape sequences
|
|
9
|
+
# produce bytes that are invalid in the source encoding (e.g. `"\xf0-\xff"`). MRI and
|
|
10
|
+
# Prism accept such literals, but the `parser` builder rejects them with "literal
|
|
11
|
+
# contains escape sequences incompatible with UTF-8", which would discard the structure
|
|
12
|
+
# of an otherwise valid file.
|
|
13
|
+
module LenientLiterals
|
|
14
|
+
extend T::Sig
|
|
15
|
+
|
|
16
|
+
# @param token [Array] The `[value, location]` pair the lexer emits for a literal.
|
|
17
|
+
# @return [String] The literal value, skipping the encoding validity check.
|
|
18
|
+
sig { params(token: T::Array[BasicObject]).returns(BasicObject) }
|
|
19
|
+
def string_value(token)
|
|
20
|
+
token.fetch(0)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module SimpleCov
|
|
5
|
+
module Formatter
|
|
6
|
+
class AIFormatter
|
|
7
|
+
class ASTResolver
|
|
8
|
+
# Recognizes constant assignments whose value is a `Struct.new` / `Class.new` /
|
|
9
|
+
# `Module.new` / `Data.define` block. These bind an anonymous class or module to the
|
|
10
|
+
# constant, so methods defined inside the block belong to that constant rather than to
|
|
11
|
+
# the enclosing lexical scope.
|
|
12
|
+
module MetaclassResolver
|
|
13
|
+
extend T::Sig
|
|
14
|
+
|
|
15
|
+
# Recognized builders mapped to the constructor method that signals the pattern.
|
|
16
|
+
BUILDERS = T.let({ 'Struct' => :new, 'Class' => :new, 'Module' => :new, 'Data' => :define }.freeze,
|
|
17
|
+
T::Hash[String, Symbol])
|
|
18
|
+
# Every AST node type that attaches a block to a call: `do … end` / `{ … }` blocks,
|
|
19
|
+
# numbered-parameter blocks (`{ _1 }`) and Ruby 3.4 `it` blocks.
|
|
20
|
+
BLOCK_TYPES = T.let(%i[block numblock itblock].freeze, T::Array[Symbol])
|
|
21
|
+
|
|
22
|
+
# @param node [Parser::AST::Node] A `:casgn` constant-assignment node.
|
|
23
|
+
# @return [String, nil] The builder label ("Struct"/"Class"/"Module"/"Data"), or nil
|
|
24
|
+
# if the assignment is not a recognized metaprogramming class definition.
|
|
25
|
+
sig { params(node: Parser::AST::Node).returns(T.nilable(String)) }
|
|
26
|
+
def self.builder_kind(node)
|
|
27
|
+
send_node = builder_send(node)
|
|
28
|
+
return nil unless send_node
|
|
29
|
+
|
|
30
|
+
receiver = NodeChildren.node_at(send_node, 0)
|
|
31
|
+
return nil unless receiver && receiver.type == :const
|
|
32
|
+
|
|
33
|
+
kind = NodeChildren.symbol_at(receiver, 1).to_s
|
|
34
|
+
kind if BUILDERS[kind] == NodeChildren.symbol_at(send_node, 1)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param node [Parser::AST::Node] Any AST node.
|
|
38
|
+
# @return [Boolean] Whether the node attaches a block to a call (see {BLOCK_TYPES}).
|
|
39
|
+
sig { params(node: Parser::AST::Node).returns(T::Boolean) }
|
|
40
|
+
def self.block?(node)
|
|
41
|
+
BLOCK_TYPES.include?(node.type)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The method call a block node is attached to.
|
|
45
|
+
#
|
|
46
|
+
# @param node [Parser::AST::Node] A node whose type is one of {BLOCK_TYPES}.
|
|
47
|
+
# @return [Parser::AST::Node, nil] The `:send` node the block decorates, or nil when
|
|
48
|
+
# the block belongs to something else (`super do … end`, `yield { … }`).
|
|
49
|
+
sig { params(node: Parser::AST::Node).returns(T.nilable(Parser::AST::Node)) }
|
|
50
|
+
def self.block_call(node)
|
|
51
|
+
call = NodeChildren.required_node_at(node, 0)
|
|
52
|
+
call if call.type == :send
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The call a block-valued constant assignment wraps, or nil when the assignment has no
|
|
56
|
+
# block value: a plain value, or the value-less `:casgn` targets that sit inside a
|
|
57
|
+
# multiple assignment (`MAJOR, MINOR = …`) or an or-assignment (`FOO ||= …`).
|
|
58
|
+
sig { params(node: Parser::AST::Node).returns(T.nilable(Parser::AST::Node)) }
|
|
59
|
+
def self.builder_send(node)
|
|
60
|
+
value = NodeChildren.node_at(node, 2)
|
|
61
|
+
return nil unless value && block?(value)
|
|
62
|
+
|
|
63
|
+
block_call(value)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private_class_method :builder_send
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module SimpleCov
|
|
5
|
+
module Formatter
|
|
6
|
+
class AIFormatter
|
|
7
|
+
class ASTResolver
|
|
8
|
+
# Typed access to the children of Parser AST nodes. Parser types every child as untyped
|
|
9
|
+
# while each node type the resolver reads has one known shape (`:def` holds its name
|
|
10
|
+
# Symbol first, `:casgn` its scope node, name Symbol and value node), so the casts live
|
|
11
|
+
# here and every call site names the child it reads and nothing else.
|
|
12
|
+
module NodeChildren
|
|
13
|
+
extend T::Sig
|
|
14
|
+
|
|
15
|
+
# @param node [Parser::AST::Node] The parent node.
|
|
16
|
+
# @param index [Integer] The position of the child.
|
|
17
|
+
# @return [Parser::AST::Node, nil] The child node, or nil when the child is absent.
|
|
18
|
+
sig { params(node: Parser::AST::Node, index: Integer).returns(T.nilable(Parser::AST::Node)) }
|
|
19
|
+
def self.node_at(node, index)
|
|
20
|
+
T.cast(node.children[index], T.nilable(Parser::AST::Node))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param node [Parser::AST::Node] The parent node.
|
|
24
|
+
# @param index [Integer] The position of a child the grammar guarantees to be present.
|
|
25
|
+
# @return [Parser::AST::Node] The child node.
|
|
26
|
+
sig { params(node: Parser::AST::Node, index: Integer).returns(Parser::AST::Node) }
|
|
27
|
+
def self.required_node_at(node, index)
|
|
28
|
+
T.cast(node.children[index], Parser::AST::Node)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param node [Parser::AST::Node] The parent node.
|
|
32
|
+
# @param index [Integer] The position of a child the grammar guarantees to be a Symbol.
|
|
33
|
+
# @return [Symbol] The child.
|
|
34
|
+
sig { params(node: Parser::AST::Node, index: Integer).returns(Symbol) }
|
|
35
|
+
def self.symbol_at(node, index)
|
|
36
|
+
T.cast(node.children[index], Symbol)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param node [Parser::AST::Node] A `:lvar` or `:ivar` node, whose only child is its name.
|
|
40
|
+
# @return [Symbol] The variable's name.
|
|
41
|
+
sig { params(node: Parser::AST::Node).returns(Symbol) }
|
|
42
|
+
def self.variable_name(node)
|
|
43
|
+
T.cast(node.children[0], Symbol)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param node [Parser::AST::Node] A `:sym` or `:str` literal node.
|
|
47
|
+
# @return [String] The literal's value as a String.
|
|
48
|
+
sig { params(node: Parser::AST::Node).returns(String) }
|
|
49
|
+
def self.literal_value(node)
|
|
50
|
+
T.cast(node.children[0], T.any(Symbol, String)).to_s
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|