specguard-ruby 0.3.4 → 0.3.6

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: 43e3d2ff78fc5e1750d77197ff2ef15c9815040147c4e52c51dd71855261b5c1
4
- data.tar.gz: e55c3f9bdcaa0a21f60fb8654ee8848a55c06b6ceb282ac523390dfbec07f12d
3
+ metadata.gz: a0b4156ddf8b2beed6ac009599c80e2be04d0ce213c44bd020bcf58c7bbb5bca
4
+ data.tar.gz: 21c4cad8aa165736d247e3092bb22480d3502525cb89d5e7e183f1bf69db69f2
5
5
  SHA512:
6
- metadata.gz: e5603b3d5deaa9e5a8119c469a8a142f4bb9527b231866e4a3c57d7face3319fb70ece91d19c81f8cb9094d48363d887ab81175e2c9897148f730c8ac72ef4b5
7
- data.tar.gz: e5a67e10d778c961e4cb8edf743db91861cbf350e15ff305370ad42d102e4d28aaf25c591617c47c73d090a77d18b3faf6cb529dd8570f18245e38402168e123
6
+ metadata.gz: 938b3a7dd2a6f20d75a1493346668ce07236a4db6e0d867d35166dc42c9efe1e76581550793ab475ca21c14ae0006cb5668f2755174f10c00c49de108a90ea63
7
+ data.tar.gz: 0b00056261a247ac2f3c31484a918fb371cc4abb05c2f0a2c2b932148633a1a57b3fa487a6d95d150e25066e23ca5952fcc575a6aaa03059224c3092b8b740e1
data/README.md CHANGED
@@ -33,7 +33,9 @@ The plugin rides alongside Minitest's own reporters (the suite's output is uncha
33
33
  envelope per process with the same field names the RSpec formatter sends, and never fails the run:
34
34
  a refused or unreachable delivery costs one line on stderr and a line in the local sink. Without
35
35
  `SPECGUARD_API_KEY` nothing is sent — the run is appended to the local development record, exactly
36
- as the RSpec formatter behaves. For a deterministic CI attachment where plugin discovery must not
36
+ as the RSpec formatter behaves. Parameterized tests the `define_method("test_x_#{param}")` loop
37
+ idiom — ship one row per instance, not one row per definition site, so each instance's outcome and
38
+ duration are tracked on their own. For a deterministic CI attachment where plugin discovery must not
37
39
  be assumed, require it explicitly before the run:
38
40
 
39
41
  ```bash
@@ -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
@@ -49,14 +69,23 @@ module SpecGuard
49
69
  end
50
70
  end
51
71
 
72
+ # Internal, never on the wire: `record` stashes the result's own method
73
+ # name under this key so `uniquify_ids!` can re-identify colliding rows
74
+ # at delivery time, and `uniquify_ids!` strips it again before the
75
+ # payload is built. The underscore prefix exists so a leak onto the
76
+ # wire would be loud in any payload diff rather than plausible.
77
+ RESULT_NAME_KEY = "_specguard_result_name"
78
+
52
79
  def initialize(configuration: SpecGuard::RSpec.configuration,
53
80
  transport: nil, output: $stderr,
81
+ annotations: SpecGuard::RSpec::AnnotationLookup.new,
54
82
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
55
83
  @configuration = configuration
56
84
  # Injected for the specs; the real thing is built the same way the
57
85
  # RSpec formatter builds it, from the same configuration object.
58
86
  @transport = transport
59
87
  @output = output
88
+ @annotations = annotations
60
89
  @clock = clock
61
90
  @rows = []
62
91
  @started_at = nil
@@ -82,7 +111,12 @@ module SpecGuard
82
111
  # exit code.
83
112
  def record(result)
84
113
  row = row_for(result)
85
- @rows << row if row
114
+ return unless row
115
+
116
+ # Stashed for `uniquify_ids!` (and stripped there) — the one
117
+ # unique-per-result token a Minitest row has. See that method.
118
+ row[RESULT_NAME_KEY] = result.name.to_s
119
+ @rows << row
86
120
  rescue ScriptError, StandardError
87
121
  nil
88
122
  end
@@ -93,6 +127,7 @@ module SpecGuard
93
127
  def report
94
128
  return if @rows.empty?
95
129
 
130
+ uniquify_ids!
96
131
  deliver(payload)
97
132
  rescue ScriptError, StandardError => e
98
133
  warn_once("could not ship test telemetry (#{e.class}: #{e.message}). The test run is unaffected.")
@@ -107,6 +142,35 @@ module SpecGuard
107
142
 
108
143
  private
109
144
 
145
+ # The definition site is NOT unique per result for a generated test:
146
+ # `define_method("test_x_#{param}")` in a loop (or any
147
+ # generate_tests-style helper) stamps every instance with the SAME
148
+ # `source_location` — the define_method call site — so N results share
149
+ # one id, and the platform's ingest upserts
150
+ # `unique_by %i[test_run_id example_id]` silently drops every repeat
151
+ # but the first, taking each dropped row's outcome and duration with
152
+ # it. `@rows` is complete here — every `record` has returned, and
153
+ # parallel mode records from its worker threads through this one shared
154
+ # reporter — so the collision is detectable exactly once, at delivery.
155
+ # Every member of a colliding group is re-identified as
156
+ # `"#{file}:#{line}##{method_name}"`: a method name is unique per
157
+ # class and deterministic, so the suffixed ids are stable across runs.
158
+ # ALL members take the suffix, never just later arrivals — parallel
159
+ # mode's arrival order is fork scheduling, so "the first row keeps the
160
+ # bare id" would make identity depend on it. Rows whose id no other
161
+ # row claims — every plain `def test_` suite among them — keep the id
162
+ # they always had, byte for byte.
163
+ def uniquify_ids!
164
+ counts = Hash.new(0)
165
+ @rows.each { |row| counts[row["id"]] += 1 }
166
+ @rows.each do |row|
167
+ name = row.delete(RESULT_NAME_KEY)
168
+ next unless counts[row["id"]] > 1 && !name.to_s.empty?
169
+
170
+ row["id"] = "#{row["id"]}##{name}"
171
+ end
172
+ end
173
+
110
174
  # The envelope, in the platform's own field names (`Ingest::Payload`:
111
175
  # commit_sha / branch / ci_run_id / duration_seconds / specs) — the same
112
176
  # rule the RSpec formatter states at its `#payload`: this gem's settings
@@ -175,15 +239,22 @@ module SpecGuard
175
239
 
176
240
  file = relative_path(file)
177
241
  name = "#{result.klass}##{result.name}"
242
+ line_number = line&.to_i
243
+ intent = annotation_for(file, line_number)
178
244
  {
179
245
  # Minitest has no RSpec-style rerun path or nested ids, so the
180
- # definition site is the identity: stable across runs (which is
181
- # what the platform's per-test identity needs) and unique per
182
- # definition (which is what a row needs).
246
+ # definition site is the row's id the run-local primary key for
247
+ # this one delivered payload, never a cross-run stable identity
248
+ # (cross-run matching remains name + file, the platform's own
249
+ # rule). A definition site is NOT unique per result:
250
+ # `define_method`-generated tests stamp every instance with the
251
+ # same location, so rows can collide on this id within a run, and
252
+ # `uniquify_ids!` is what re-identifies those rows at delivery
253
+ # time. Nothing here promises uniqueness it does not have.
183
254
  "id" => "#{file}:#{line}",
184
255
  "spec_file_path" => file,
185
256
  "file_path" => file,
186
- "line_number" => line&.to_i,
257
+ "line_number" => line_number,
187
258
  "name" => name,
188
259
  "duration" => result.time,
189
260
  # The platform's three outcomes, mapped from Minitest's questions:
@@ -192,10 +263,37 @@ module SpecGuard
192
263
  # Minitest distinguishes assertion failures from exceptions, the
193
264
  # wire contract does not, and inventing a fourth outcome here would
194
265
  # be a lie the schema refuses.
195
- "outcome" => outcome_for(result)
266
+ "outcome" => outcome_for(result),
267
+ # The RSpec formatter's rationale, restated: the key is written
268
+ # explicitly because a present key says "we looked", where an absent
269
+ # key is indistinguishable from "this producer does not report
270
+ # annotations" — and without it the platform refuses the whole run
271
+ # (`Ingest::Payload` requires `status` on EVERY row). `intent` is
272
+ # likewise explicit: null when unannotated, which is exactly what
273
+ # the platform demands of an unannotated row.
274
+ "status" => intent.nil? ? STATUS_UNANNOTATED : STATUS_ANNOTATED,
275
+ "intent" => intent
196
276
  }
197
277
  end
198
278
 
279
+ # The lookup, inside its own envelope. `record`'s rescue would drop the
280
+ # whole row — example, duration, outcome and all — to learn one row's
281
+ # annotation, which is the trade the RSpec formatter already refused:
282
+ # its annotation lookup runs inside a nested guard so a blow-up costs
283
+ # the row only its annotation. The lookup degrades verdicts it could
284
+ # not reach to nil itself (an unusable validator ships unannotated by
285
+ # design); this envelope is for what escapes it anyway — file reads the
286
+ # lookup does not expect, encoding faults, anything else — and it
287
+ # routes through {#warn_once}, so the operator hears about it exactly
288
+ # once however many rows are affected.
289
+ def annotation_for(file, line)
290
+ @annotations.intent_for(file: file, line: line)
291
+ rescue ScriptError, StandardError => e
292
+ warn_once("could not resolve annotations (#{e.class}: #{e.message}). " \
293
+ "Rows continue unannotated. The test run is unaffected.")
294
+ nil
295
+ end
296
+
199
297
  def outcome_for(result)
200
298
  return "pending" if result.skipped?
201
299
  return "failed" unless result.passed?
@@ -244,25 +244,28 @@ module SpecGuard
244
244
  def empty_reason(selection)
245
245
  case selection.mode
246
246
  when :changed then changed_empty_reason(selection)
247
- else "no *_spec.rb found under #{Dir.pwd}"
247
+ else "no *_spec.rb or *_test.rb file found under #{Dir.pwd}"
248
248
  end
249
249
  end
250
250
 
251
251
  # Names the filter that actually emptied the selection. Saying "nothing in
252
- # the diff matched *_spec.rb" when a spec file demonstrably changed —
252
+ # the diff matched" when a test file demonstrably changed —
253
253
  # just not under this directory — is worse than saying nothing: it reads
254
- # 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.
255
258
  def changed_empty_reason(selection)
256
259
  stats = selection.stats
257
260
  base = selection.base
258
261
 
259
- 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?
260
263
 
261
264
  if stats.changed.zero?
262
265
  "nothing changed against #{base}"
263
266
  elsif stats.spec_matches.zero?
264
267
  "#{stats.changed} file#{'s' unless stats.changed == 1} changed against #{base}, " \
265
- "none matching *_spec.rb"
268
+ "none matching *_spec.rb or *_test.rb"
266
269
  else
267
270
  changed_excluded_reason(stats, base)
268
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)
@@ -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.4"
10
+ VERSION = "0.3.6"
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.4
4
+ version: 0.3.6
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-31 00:00:00.000000000 Z
11
+ date: 2026-09-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json