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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/README.md +216 -45
  4. data/lib/simplecov-ai/ast_resolver/bypass_scanner.rb +69 -0
  5. data/lib/simplecov-ai/ast_resolver/dynamic_method_resolver.rb +61 -0
  6. data/lib/simplecov-ai/ast_resolver/lenient_literals.rb +26 -0
  7. data/lib/simplecov-ai/ast_resolver/metaclass_resolver.rb +71 -0
  8. data/lib/simplecov-ai/ast_resolver/node_children.rb +56 -0
  9. data/lib/simplecov-ai/ast_resolver/node_classifier.rb +158 -0
  10. data/lib/simplecov-ai/ast_resolver/parser_backend.rb +174 -0
  11. data/lib/simplecov-ai/ast_resolver/receiver_resolver.rb +61 -0
  12. data/lib/simplecov-ai/ast_resolver/semantic_node.rb +47 -16
  13. data/lib/simplecov-ai/ast_resolver.rb +45 -115
  14. data/lib/simplecov-ai/configuration.rb +101 -22
  15. data/lib/simplecov-ai/constants.rb +0 -3
  16. data/lib/simplecov-ai/line_span.rb +37 -0
  17. data/lib/simplecov-ai/markdown_builder/branch_enricher.rb +81 -38
  18. data/lib/simplecov-ai/markdown_builder/bypass_compiler.rb +41 -43
  19. data/lib/simplecov-ai/markdown_builder/decode_guard.rb +58 -0
  20. data/lib/simplecov-ai/markdown_builder/deficit_compiler.rb +61 -63
  21. data/lib/simplecov-ai/markdown_builder/deficit_formatter.rb +100 -86
  22. data/lib/simplecov-ai/markdown_builder/deficit_group.rb +84 -0
  23. data/lib/simplecov-ai/markdown_builder/deficit_grouper.rb +85 -42
  24. data/lib/simplecov-ai/markdown_builder/inline_code.rb +33 -0
  25. data/lib/simplecov-ai/markdown_builder/report_budget.rb +88 -0
  26. data/lib/simplecov-ai/markdown_builder/section_writer.rb +86 -0
  27. data/lib/simplecov-ai/markdown_builder/skip_regions.rb +128 -0
  28. data/lib/simplecov-ai/markdown_builder/snippet_formatter.rb +117 -18
  29. data/lib/simplecov-ai/markdown_builder/source_lines.rb +28 -0
  30. data/lib/simplecov-ai/markdown_builder.rb +107 -74
  31. data/lib/simplecov-ai/version.rb +1 -1
  32. data/lib/simplecov-ai.rb +69 -12
  33. data.tar.gz.sig +0 -0
  34. metadata +88 -71
  35. metadata.gz.sig +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fb0ee3780e192789926c8cf8b891596209c8304525436d11091f34ab4d7f86c6
4
- data.tar.gz: e32fbea00cde5bad4d546942f69461f646aa0dbf687731401d3c9a249d692015
3
+ metadata.gz: c74a6bfde86ee9beeaf6a02adb04c043878e1c2d7c6e4fc173a00a9d479998de
4
+ data.tar.gz: 98dc5d09ad61af95a6ffd131ee5b96a07fbda217000bb1e4af032d8024ba2fd8
5
5
  SHA512:
6
- metadata.gz: 37a92b9aad862aa0952f7ab712182b35e55d04ce4982656f8de5bc4ead4a00827bd92fa74e272978af69d84f9a79268e98fc20c25e50afce02d0cb73c7b83013
7
- data.tar.gz: e0eef35acaab28b50c50a9d0d5fbcf5166b66fa67dbc83382b95a9bb29e889625c3be4b33d0d2bcaf3316c386e2f4d38facbf85135e097f028f02d0e38676b51
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 custom `SimpleCov::Formatter` designed explicitly for consumption by Large Language Models (LLMs) and autonomous engineering agents.
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 generate massive HTML files or exhaustive JSON/console outputs detailing every line number. This overwhelms strict LLM token constraints and relies on highly volatile line numbers. `simplecov-ai` solves this by generating a concise, structurally optimized **Markdown document** containing only the exact missing semantic coverage blocks.
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
- ## Why use simplecov-ai?
12
+ ## What the digest contains
8
13
 
9
- - **Semantic Resolution:** Instead of volatile line numbers, missing coverage is resolved via Abstract Syntax Tree (AST) mapping into immutable semantic groupings (e.g., Class, Module, Instance Method).
10
- - **Maximum Token Conservation:** Fully covered files are completely omitted. If the report exceeds size limits, it safely truncates the output prioritizing the lowest-coverage files.
11
- - **Actionable Delta Directives:** Missing branches and lines are mapped directly to method names, letting the AI instantly search the code and write targeted specs.
12
- - **Directive Auditing:** Explicitly reports `:nocov:` bypasses, ensuring artificial metric inflation is completely transparent to the reviewing AI.
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
- ## Installation
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
- Add this line to your application's `Gemfile` strictly in the `test` group:
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 & Configuration
51
+ ## Usage
26
52
 
27
- Require and configure the formatter in your test helper (`spec_helper.rb` or `test_helper.rb`) after requiring `simplecov`:
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
- # Optional Configuration (defaults shown below):
34
- SimpleCov::Formatter::AIFormatter.configure do |config|
35
- config.report_path = 'coverage/ai_report.md' # Output location
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.start do
44
- # Combine with your existing formatters
45
- SimpleCov.formatters = SimpleCov::Formatter::MultiFormatter.new([
46
- SimpleCov::Formatter::HTMLFormatter,
47
- SimpleCov::Formatter::AIFormatter
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
- ## Example Output
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
- The output is written to `coverage/ai_report.md` (or your configured path), perfect for providing directly as context to an LLM:
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:** 92.5%
60
- **Global Branch Coverage:** 88.0%
61
- **Generated At:** 2026-04-21T23:40:44+09:00 (Local Timezone)
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/my_gem/client.rb`
67
- - `MyGem::Client#authenticate!`
68
- - **Branch Deficit:** Missing coverage for conditional evaluation handling `ExpiredTokenError`.
69
- - `MyGem::Client#initialize`
70
- - **Line Deficit:** Variable initialization state uncovered.
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/my_gem/parser/processor.rb`
73
- - `MyGem::Parser::Processor.parse_stream`
74
- - **Branch Deficit:** Missing coverage for early-exit condition `break if stream.closed?` (Occurrence 1 of 2).
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/my_gem/legacy_handler.rb`
79
- - `MyGem::LegacyHandler#obsolete_action`
80
- - **Bypass Present:** Contains `:nocov:` directive artificially ignoring coverage (Occurrence 1 of 1).
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
- ## Error Handling
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
- Adhering to fail-fast principles, if the AST parser encounters structurally unparseable Ruby code or corrupt telemetry, it will gracefully degrade or explicitly fail. It will not silently ignore failures or emit corrupted artifacts.
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