specguard-ruby 0.3.3 → 0.3.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d91e0fec640df334c1ee281f06852adb53172514aa6bc357346d0ba76c725fe
4
- data.tar.gz: c2f3974d36260c4cdf33296e85f3a44879be617122ce3291a17538d2d7e4e68f
3
+ metadata.gz: 9a82ff31aaa51455637e6500a2f4cebda11b50dad52947bd36ce535ada84bc1b
4
+ data.tar.gz: 658887600bd0fec337a1d7ddd372b3c481fa761c95ff3c92c25f12fc6e0fcf91
5
5
  SHA512:
6
- metadata.gz: '019c9e92094afec846e8184c39ce185c47eb564b6cd4b6ec9ed2198e1876865b02efd8b198ac2d161629840a8b188991fc4fd347265bc47b5a67b513d4498a60'
7
- data.tar.gz: 411ea2d03fa8be62ec65f54a1341cd6ca8926dfdf481618b86d4551c111d84312efcc3d9dacfabe015bf9dee254a956e468137f6a4bef6468635302d673e4266
6
+ metadata.gz: 88589ddb1374aa792be0199049b7bb204b6479850831db80340344f4d624db96dc9b7c1c345d6c95c24bbb6728b4e711ab7a338899a7a815c09e6eb53f47c4b2
7
+ data.tar.gz: e11192524e6f248aa16d42c522664863a84894b85987f07ef5f54e5b9c2439bdb6146b0f3c693196d0fcc627f85923f24c5655ed330a9809926f1519802e1e0f
data/README.md CHANGED
@@ -43,8 +43,9 @@ bundle exec ruby -rminitest/specguard_plugin -e 'Minitest.extensions << "specgua
43
43
  ## The linter — `specguard-lint`
44
44
 
45
45
  Validates `# @intent:` annotations in changed (or all) `*_spec.rb` files against the OpenTestIntent
46
- JSON Schema. Exits `1` on a malformed annotation; **never** fails on a *missing* one (adoption is
47
- opt-in and gradual).
46
+ JSON Schema. Exits `1` on a malformed annotation or a well-formed but unreachable one (stacked
47
+ above another comment-form `@intent:` line, so the one-line lookback never claims it) — and
48
+ **never** fails on a *missing* one (adoption is opt-in and gradual).
48
49
 
49
50
  ```bash
50
51
  bundle exec specguard-lint --changed # CI mode: only files in the current diff
@@ -278,7 +279,9 @@ the `read` kind. The binary's own UTF-8 refusal prose (`input is not well-formed
278
279
 
279
280
  Every way the backend can fail — the binary is missing, will not execute, exits with something that
280
281
  is not a verdict, or emits output that is not a report — is **exit 2**, the linter's "could not do
281
- my job" code. It never becomes exit 1, which means "an annotation is malformed" and nothing else.
282
+ my job" code. It never becomes exit 1, which means "an annotation is malformed" or well-formed
283
+ but unreachable: stacked above another comment-form `@intent:` line, so the one-line lookback
284
+ never claims it — and nothing else.
282
285
 
283
286
  ## The formatter — `SpecGuard::RSpecFormatter`
284
287
 
@@ -10,6 +10,17 @@ require "fileutils"
10
10
  # minitest-only consumer's machine.
11
11
  require_relative "../rspec/configuration"
12
12
 
13
+ # The annotation lookup is the third framework-free half: what counts as a
14
+ # test's annotation is a property of the source line, not of the framework
15
+ # that ran it, so the Minitest reporter asks the same {AnnotationLookup} the
16
+ # RSpec formatter does rather than carrying a second extractor (whose header
17
+ # already warns that a second extractor guarantees scanner and reporter
18
+ # eventually disagree about what an annotation is). Requiring it pulls the
19
+ # linter's chain — the same direction-safe require the formatter itself makes;
20
+ # `specguard/rspec` does not require `rspec/core`, so a minitest-only machine
21
+ # stays loadable.
22
+ require_relative "../rspec/annotation_lookup"
23
+
13
24
  # The Minitest half of `specguard-ruby`. Everything the RSpec formatter knows
14
25
  # about the platform — the envelope's field names, the transport's
15
26
  # never-raise contract, the switch that a missing key is — is framework-free
@@ -18,7 +29,10 @@ require_relative "../rspec/configuration"
18
29
  # ship first, neither containing any RSpec). This adapter contributes only the
19
30
  # part that is genuinely Minitest's: turning `Minitest::Result` objects into
20
31
  # the same row shape the RSpec formatter emits, at the moment Minitest hands
21
- # them to a reporter.
32
+ # them to a reporter — and attaching each test's annotation the same way the
33
+ # formatter does, through the shared {SpecGuard::RSpec::AnnotationLookup} and
34
+ # its one-line lookback (SPGD-12 §2), so a Minitest row carries the same
35
+ # `status` / `intent` pair a RSpec row does.
22
36
  #
23
37
  # It is a duck-typed Minitest reporter (`start` / `record` / `report` /
24
38
  # `passed?`) rather than a subclass of `Minitest::AbstractReporter`, because
@@ -36,6 +50,12 @@ require_relative "../rspec/configuration"
36
50
  module SpecGuard
37
51
  module Minitest
38
52
  class Reporter
53
+ # `Ingest::Payload::STATUSES`, restated — the same call the RSpec
54
+ # formatter makes. The platform validates every row against this pair,
55
+ # so they are the contract and not a local naming choice.
56
+ STATUS_ANNOTATED = "annotated"
57
+ STATUS_UNANNOTATED = "unannotated"
58
+
39
59
  # Rows with no usable location are dropped rather than mangled: a row
40
60
  # whose file cannot be named fits no by-file aggregate on the platform
41
61
  # and would surface as noise. The RSpec formatter's `capture` makes the
@@ -51,12 +71,14 @@ module SpecGuard
51
71
 
52
72
  def initialize(configuration: SpecGuard::RSpec.configuration,
53
73
  transport: nil, output: $stderr,
74
+ annotations: SpecGuard::RSpec::AnnotationLookup.new,
54
75
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
55
76
  @configuration = configuration
56
77
  # Injected for the specs; the real thing is built the same way the
57
78
  # RSpec formatter builds it, from the same configuration object.
58
79
  @transport = transport
59
80
  @output = output
81
+ @annotations = annotations
60
82
  @clock = clock
61
83
  @rows = []
62
84
  @started_at = nil
@@ -175,6 +197,8 @@ module SpecGuard
175
197
 
176
198
  file = relative_path(file)
177
199
  name = "#{result.klass}##{result.name}"
200
+ line_number = line&.to_i
201
+ intent = annotation_for(file, line_number)
178
202
  {
179
203
  # Minitest has no RSpec-style rerun path or nested ids, so the
180
204
  # definition site is the identity: stable across runs (which is
@@ -183,7 +207,7 @@ module SpecGuard
183
207
  "id" => "#{file}:#{line}",
184
208
  "spec_file_path" => file,
185
209
  "file_path" => file,
186
- "line_number" => line&.to_i,
210
+ "line_number" => line_number,
187
211
  "name" => name,
188
212
  "duration" => result.time,
189
213
  # The platform's three outcomes, mapped from Minitest's questions:
@@ -192,10 +216,37 @@ module SpecGuard
192
216
  # Minitest distinguishes assertion failures from exceptions, the
193
217
  # wire contract does not, and inventing a fourth outcome here would
194
218
  # be a lie the schema refuses.
195
- "outcome" => outcome_for(result)
219
+ "outcome" => outcome_for(result),
220
+ # The RSpec formatter's rationale, restated: the key is written
221
+ # explicitly because a present key says "we looked", where an absent
222
+ # key is indistinguishable from "this producer does not report
223
+ # annotations" — and without it the platform refuses the whole run
224
+ # (`Ingest::Payload` requires `status` on EVERY row). `intent` is
225
+ # likewise explicit: null when unannotated, which is exactly what
226
+ # the platform demands of an unannotated row.
227
+ "status" => intent.nil? ? STATUS_UNANNOTATED : STATUS_ANNOTATED,
228
+ "intent" => intent
196
229
  }
197
230
  end
198
231
 
232
+ # The lookup, inside its own envelope. `record`'s rescue would drop the
233
+ # whole row — example, duration, outcome and all — to learn one row's
234
+ # annotation, which is the trade the RSpec formatter already refused:
235
+ # its annotation lookup runs inside a nested guard so a blow-up costs
236
+ # the row only its annotation. The lookup degrades verdicts it could
237
+ # not reach to nil itself (an unusable validator ships unannotated by
238
+ # design); this envelope is for what escapes it anyway — file reads the
239
+ # lookup does not expect, encoding faults, anything else — and it
240
+ # routes through {#warn_once}, so the operator hears about it exactly
241
+ # once however many rows are affected.
242
+ def annotation_for(file, line)
243
+ @annotations.intent_for(file: file, line: line)
244
+ rescue ScriptError, StandardError => e
245
+ warn_once("could not resolve annotations (#{e.class}: #{e.message}). " \
246
+ "Rows continue unannotated. The test run is unaffected.")
247
+ nil
248
+ end
249
+
199
250
  def outcome_for(result)
200
251
  return "pending" if result.skipped?
201
252
  return "failed" unless result.passed?
@@ -9,7 +9,7 @@ module SpecGuard
9
9
  # == The contract, and the reason it needs defending
10
10
  #
11
11
  # 0 every annotation checked is valid (including "there were none")
12
- # 1 at least one annotation is malformed
12
+ # 1 at least one annotation is malformed or unreachable
13
13
  # 2 the linter could not do its job — misuse, or the tool itself broken
14
14
  #
15
15
  # Ruby does not give you this for free; it actively works against it.
@@ -102,9 +102,11 @@ module SpecGuard
102
102
  # Every annotation checked was valid — or there were none to check.
103
103
  # "Lint, don't require": a missing annotation is never an error.
104
104
  EXIT_OK = 0
105
- # One or more annotations are malformed. The only code produced by
106
- # inspecting content, and the only path that reaches it is a failed
107
- # {Linter::Result}.
105
+ # One or more annotations are malformed or well-formed but
106
+ # unreachable (SPGD-900: stacked above another comment-form `@intent:`
107
+ # line, so the one-line lookback never claims it, a dead contract that
108
+ # is still a failure). The only code produced by inspecting content,
109
+ # and the only path that reaches it is a failed {Linter::Result}.
108
110
  EXIT_MALFORMED = 1
109
111
  # The linter could not do its job: bad flags, `--changed` outside a git
110
112
  # repository, a validator that could not be resolved, or an unexpected
@@ -140,6 +142,14 @@ module SpecGuard
140
142
 
141
143
  results = backend.check(selection.files)
142
144
 
145
+ # The structural pass (SPGD-900): annotations that are valid in
146
+ # isolation but can never be extracted — stacked consecutive
147
+ # comment-form `@intent:` lines, where the one-line lookback
148
+ # (SPGD-12 §2) claims only the line just above the example. These are
149
+ # Linter::Results like any other, so both renderers and the exit code
150
+ # pick them up with no second path to keep in step.
151
+ results += unreachable_results(selection.files)
152
+
143
153
  # Computed once, here, and handed to whichever renderer runs. `--json`
144
154
  # is a second renderer over this list, not a second code path: the exit
145
155
  # code below is the same expression it always was, and the document's
@@ -162,6 +172,19 @@ module SpecGuard
162
172
 
163
173
  private
164
174
 
175
+ # {Scanner}'s structural findings are annotations, not file problems:
176
+ # each carries a real `file:line`, so they become failing line-scoped
177
+ # {Linter::Result}s exactly like a malformed payload would. A file the
178
+ # backend could not read contributes nothing here — the backend's own
179
+ # KIND_READ result already reports it, and this pass cannot be positional
180
+ # about a file it never saw.
181
+ def unreachable_results(paths)
182
+ Scanner.unreachable_findings(paths).map do |finding|
183
+ Linter::Result.new(file: finding.file, line: finding.line,
184
+ kind: finding.kind, problem: finding.problem)
185
+ end
186
+ end
187
+
165
188
  # One line per run naming the implementation that produced the verdicts.
166
189
  # Since the SPGD-867 cutover there is exactly one implementation — the
167
190
  # resolved `validate-intent` binary — and the line says which one,
@@ -221,25 +244,28 @@ module SpecGuard
221
244
  def empty_reason(selection)
222
245
  case selection.mode
223
246
  when :changed then changed_empty_reason(selection)
224
- else "no *_spec.rb found under #{Dir.pwd}"
247
+ else "no *_spec.rb or *_test.rb file found under #{Dir.pwd}"
225
248
  end
226
249
  end
227
250
 
228
251
  # Names the filter that actually emptied the selection. Saying "nothing in
229
- # the diff matched *_spec.rb" when a spec file demonstrably changed —
252
+ # the diff matched" when a test file demonstrably changed —
230
253
  # just not under this directory — is worse than saying nothing: it reads
231
- # as a conclusion and stops the reader looking.
254
+ # as a conclusion and stops the reader looking. Both naming conventions
255
+ # are named because either would have been selected; a Minitest-only
256
+ # repository must not be told the silence is about `*_spec.rb` files it
257
+ # does not have.
232
258
  def changed_empty_reason(selection)
233
259
  stats = selection.stats
234
260
  base = selection.base
235
261
 
236
- return "nothing in the diff against #{base} matched *_spec.rb" if stats.nil?
262
+ return "nothing in the diff against #{base} matched a *_spec.rb or *_test.rb file" if stats.nil?
237
263
 
238
264
  if stats.changed.zero?
239
265
  "nothing changed against #{base}"
240
266
  elsif stats.spec_matches.zero?
241
267
  "#{stats.changed} file#{'s' unless stats.changed == 1} changed against #{base}, " \
242
- "none matching *_spec.rb"
268
+ "none matching *_spec.rb or *_test.rb"
243
269
  else
244
270
  changed_excluded_reason(stats, base)
245
271
  end
@@ -6,8 +6,15 @@ module SpecGuard
6
6
  module RSpec
7
7
  # Chooses which spec files the linter reads.
8
8
  #
9
- # Two modes: every `*_spec.rb` under the working directory (the default), or
10
- # only those in the current diff (`--changed`).
9
+ # Two modes: every Ruby test file under the working directory (the
10
+ # default), or only those in the current diff (`--changed`). Both modes
11
+ # recognize the two Ruby test-framework naming conventions — RSpec's
12
+ # `*_spec.rb` and Minitest's `*_test.rb` — so a suite is visible to the
13
+ # linter whichever framework wrote it. The default walk scopes Minitest
14
+ # files to `test/` (the directory both Rails and `minitest` own as the
15
+ # convention), while `--changed` filters on the file-name suffix alone: a
16
+ # diff names paths, not directories, and a changed test file is a test
17
+ # file wherever the author put it.
11
18
  #
12
19
  # == Why `--changed` is not `git diff --name-only`
13
20
  #
@@ -41,9 +48,10 @@ module SpecGuard
41
48
  # found no annotations" from "checked 0 files" — {Selection} carries the
42
49
  # count, the emptiness, and {Stats} explaining *which* filter emptied it, so
43
50
  # the CLI can say so on stderr, accurately. A confidently wrong reason is
44
- # worse than a quiet one: a human who reads "nothing in the diff matched
45
- # *_spec.rb" stops looking. The exit code is not the lever: the spec fixes 0
46
- # for "no annotations".
51
+ # worse than a quiet one: a human who reads "nothing in the diff matched a
52
+ # test file" stops looking and on a Minitest-only repository the silence
53
+ # must not be explained in RSpec vocabulary the tree does not use. The exit
54
+ # code is not the lever: the spec fixes 0 for "no annotations".
47
55
  #
48
56
  # == Scope: `--changed` selects changed specs **under `root`**
49
57
  #
@@ -74,7 +82,20 @@ module SpecGuard
74
82
  # clean checkout of a commit) that cannot arise; locally the loud empty
75
83
  # selection is what surfaces it.
76
84
  module FileSelector
77
- DEFAULT_GLOB = "**/*_spec.rb"
85
+ # `test/**/*_test.rb` rather than `**/*_test.rb`: the `test/` directory
86
+ # is where both Rails and Minitest itself put Minitest files, and
87
+ # keeping the second convention scoped stops the walk from adopting
88
+ # unrelated `*_test.rb` files elsewhere in the tree (fixture generators,
89
+ # vendored code). `*_spec.rb` stays unscoped because `spec/` already has
90
+ # no competitor convention to fence against.
91
+ DEFAULT_GLOB = ["**/*_spec.rb", "test/**/*_test.rb"].freeze
92
+
93
+ # The `--changed` counterparts of {DEFAULT_GLOB}: git hands back paths,
94
+ # so the filter is a match over the whole path rather than a glob walk,
95
+ # and the suffix alone decides. Deliberately not directory-scoped — a
96
+ # changed Minitest file outside `test/` is still this client's business
97
+ # in the one mode that answers "what did this branch touch".
98
+ CHANGED_PATTERNS = ["*_spec.rb", "*_test.rb"].freeze
78
99
 
79
100
  # Ordered probes for the default branch when no explicit base is given.
80
101
  DEFAULT_BRANCH_REFS = %w[origin/HEAD origin/main origin/master main master].freeze
@@ -116,8 +137,9 @@ module SpecGuard
116
137
  changed ? select_changed(base: base, root: root) : select_all(root: root)
117
138
  end
118
139
 
119
- # Every `*_spec.rb` under `root`, recursively. Hidden directories are not
120
- # traversed (no `File::FNM_DOTMATCH`), so `.git` and friends are skipped.
140
+ # Every test file under `root`, recursively, in either naming
141
+ # convention. Hidden directories are not traversed (no
142
+ # `File::FNM_DOTMATCH`), so `.git` and friends are skipped.
121
143
  def select_all(root: Dir.pwd)
122
144
  files = Dir.glob(DEFAULT_GLOB, base: root).select { |f| File.file?(File.join(root, f)) }.sort
123
145
  Selection.new(files: files, mode: :all)
@@ -146,7 +168,7 @@ module SpecGuard
146
168
  # @return [[Array<String>, Stats]]
147
169
  def changed_files(base, root)
148
170
  names = diff_names(base, root)
149
- specs = names.select { |name| File.fnmatch?("*_spec.rb", name) }
171
+ specs = names.select { |name| CHANGED_PATTERNS.any? { |pattern| File.fnmatch?(pattern, name) } }
150
172
 
151
173
  top = toplevel(root)
152
174
  prefix = directory_prefix(top.empty? ? root : top)
@@ -48,6 +48,17 @@ module SpecGuard
48
48
  # of ways an annotation can fail is written down in one place.
49
49
  KIND_SCHEMA = :schema
50
50
 
51
+ # The annotation is well-formed in isolation but can never be EXTRACTED:
52
+ # it is a comment-form `@intent:` on a line whose next line is also a
53
+ # comment-form `@intent:`, so the one-line lookback (SPGD-12 §2 —
54
+ # {AnnotationLookup} claims only the comment on the line immediately
55
+ # above an example) always skips it in favour of the lower line. The
56
+ # contract is dead metadata: counted by the linter, discarded by
57
+ # extraction. Produced by {Scanner}'s structural pass, not by
58
+ # {AnnotationScanner} — each annotation is scanned in isolation there,
59
+ # which is exactly why this defect is invisible to it.
60
+ KIND_UNREACHABLE = :unreachable
61
+
51
62
  def initialize(file:, line:, intent: nil, problem: nil, kind: nil)
52
63
  super
53
64
  end
@@ -19,7 +19,8 @@ module SpecGuard
19
19
  #
20
20
  # == What counts as a failure
21
21
  #
22
- # Three things reach the same verdict from different directions:
22
+ # Three things make an annotation malformed, reaching the same verdict
23
+ # from different directions:
23
24
  #
24
25
  # * the payload could not be captured off the line at all
25
26
  # (`Finding::KIND_EXTRACTION`) — a typo'd annotation;
@@ -35,6 +36,14 @@ module SpecGuard
35
36
  # valid UTF-8 — is also reported as a failure, and therefore also exits 1.
36
37
  # That matches `validate-intent`, which classifies it separately (its own
37
38
  # `read` kind) and still reports it as `FAIL` with exit 1.
39
+ #
40
+ # `Finding::KIND_UNREACHABLE` — an annotation that is well-formed in
41
+ # isolation but stacked above another comment-form `@intent:` line, so
42
+ # the one-line lookback (SPGD-12 §2) never claims it — is also reported
43
+ # as a failure, and therefore also exits 1. Nothing about it is
44
+ # malformed; the contract is dead metadata, counted by the linter and
45
+ # discarded by extraction, and the structural pass that flags it
46
+ # (SPGD-900) reports it loudly rather than letting it pass as clean.
38
47
  module Linter
39
48
  # One annotation's verdict. `problem` is set when discovery could not
40
49
  # produce an intent at all; `reasons` when the schema rejected one.
@@ -230,6 +230,107 @@ module SpecGuard
230
230
  Finding.new(file: file, line: line, kind: Finding::KIND_PARSE,
231
231
  problem: "could not parse annotation: #{e.message}")
232
232
  end
233
+
234
+ # == The structural pass: annotations the extraction can never claim
235
+ #
236
+ # Everything above validates each annotation IN ISOLATION — one payload,
237
+ # one verdict. But the formatter's extraction contract
238
+ # ({AnnotationLookup}, SPGD-12 §2) is positional: only the comment-form
239
+ # annotation on the line IMMEDIATELY ABOVE an example is inheritable.
240
+ # So when two consecutive comment-form `@intent:` lines sit above one
241
+ # `it`, the UPPER line is unreachable — silently discarded by the
242
+ # one-line lookback — while this pipeline counts it as a valid
243
+ # annotation and the run exits 0. Measured (SPGD-897 audit): 16 such
244
+ # stacked pairs shipped through `specguard-lint` exit 0, every upper
245
+ # contract dead. Reported loudly, not skipped — the same stance
246
+ # {AnnotationScanner} takes for NO_PAYLOAD.
247
+ #
248
+ # A comment-form annotation line is a comment-only line
249
+ # ({AnnotationLookup::COMMENT_LINE}) carrying the `@intent:` token. The
250
+ # trailing same-line form never matches COMMENT_LINE, so it is never
251
+ # flagged and never makes a neighbour unreachable — its annotation
252
+ # belongs to its own example's line and the lookback is not involved.
253
+ #
254
+ # @param paths [Enumerable<String>]
255
+ # @return [Array<Finding>] one per unreachable comment-form annotation,
256
+ # in file-then-line order. A file that cannot be read contributes
257
+ # nothing here — {ValidatorBackend} already reports read failures, and
258
+ # this pass has nothing positional to say about a file it never saw.
259
+ def unreachable_findings(paths)
260
+ paths.flat_map { |path| unreachable_findings_in_file(path) }
261
+ end
262
+
263
+ # @param path [String]
264
+ # @return [Array<Finding>]
265
+ def unreachable_findings_in_file(path)
266
+ begin
267
+ text = File.read(path, encoding: "UTF-8")
268
+ rescue SystemCallError, IOError
269
+ return []
270
+ end
271
+
272
+ unreachable_findings_in_text(text, file: path)
273
+ end
274
+
275
+ UNREACHABLE_ANNOTATION =
276
+ "unreachable annotation: the previous line is also a comment-form @intent:, " \
277
+ "so the one-line lookback claims only the line just above the example " \
278
+ "and this annotation is silently discarded at extraction (SPGD-12 §2)"
279
+
280
+ # A comment-only line carrying an `@intent:` token — the only form an
281
+ # example on the NEXT line may claim ({AnnotationLookup::COMMENT_LINE}).
282
+ COMMENT_INTENT_LINE = /\A\s*#.*@intent:/
283
+
284
+ # The line an annotation run may be claimed from: the first example
285
+ # keyword. The lookback rule is `example.metadata[:line_number] - 1`, so
286
+ # only the comment form on the line immediately above an example is
287
+ # inheritable — which makes an EXAMPLE the anchor of the whole rule.
288
+ #
289
+ # Anchoring on the example is what keeps this pass off annotation
290
+ # corpora with no examples at all (the recorded-binary fixtures in
291
+ # spec/fixtures/): there is no lookback there to silently discard
292
+ # anything, so there is nothing to report. The defect this pass exists
293
+ # for (SPGD-897) is stacked lines above a real example — an annotation
294
+ # the author believed was attached to the `it` under it.
295
+ EXAMPLE_LINE = /\A\s*(?:it|specify)\b/
296
+
297
+ # @param text [String] source of one file
298
+ # @param file [String] path to record on each Finding
299
+ # @return [Array<Finding>] one per comment-form `@intent:` line in a
300
+ # stacked run — a maximal run of consecutive comment-form `@intent:`
301
+ # lines immediately above an example — except the run's LAST line,
302
+ # which is the one the one-line lookback claims. For the canonical
303
+ # two-line stack above one `it`, that is the UPPER line.
304
+ def unreachable_findings_in_text(text, file:)
305
+ # A file that is not valid UTF-8 is reported once, loudly, by the
306
+ # backend as a read failure; nothing positional can be said about it,
307
+ # and `lines` below would raise on the invalid bytes.
308
+ return [] unless text.valid_encoding?
309
+
310
+ lines = text.lines
311
+ i = 0
312
+ findings = []
313
+
314
+ while i < lines.length
315
+ unless COMMENT_INTENT_LINE.match?(lines[i])
316
+ i += 1
317
+ next
318
+ end
319
+
320
+ run_start = i
321
+ i += 1 while i < lines.length && COMMENT_INTENT_LINE.match?(lines[i])
322
+ run_end = i # exclusive; lines[run_end] is the line after the run
323
+
324
+ if run_end < lines.length && EXAMPLE_LINE.match?(lines[run_end]) && run_start < run_end - 1
325
+ (run_start...(run_end - 1)).each do |j|
326
+ findings << Finding.new(file: file, line: j + 1, problem: UNREACHABLE_ANNOTATION,
327
+ kind: Finding::KIND_UNREACHABLE)
328
+ end
329
+ end
330
+ end
331
+
332
+ findings
333
+ end
233
334
  end
234
335
  end
235
336
  end
@@ -7,5 +7,5 @@
7
7
  # framework-scoped version constant a lie, so the number moved here and the
8
8
  # rspec module now points at it for back-compat.
9
9
  module SpecGuard
10
- VERSION = "0.3.3"
10
+ VERSION = "0.3.5"
11
11
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: specguard-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.3
4
+ version: 0.3.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - specguard Agent
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-29 00:00:00.000000000 Z
11
+ date: 2026-09-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json