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 +4 -4
- data/README.md +3 -1
- data/lib/specguard/minitest/reporter.rb +105 -7
- data/lib/specguard/rspec/cli.rb +8 -5
- data/lib/specguard/rspec/file_selector.rb +31 -9
- data/lib/specguard/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a0b4156ddf8b2beed6ac009599c80e2be04d0ce213c44bd020bcf58c7bbb5bca
|
|
4
|
+
data.tar.gz: 21c4cad8aa165736d247e3092bb22480d3502525cb89d5e7e183f1bf69db69f2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
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
|
|
181
|
-
#
|
|
182
|
-
#
|
|
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" =>
|
|
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?
|
data/lib/specguard/rspec/cli.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
#
|
|
46
|
-
#
|
|
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
|
-
|
|
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
|
|
120
|
-
#
|
|
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?(
|
|
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)
|
data/lib/specguard/version.rb
CHANGED
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
|
+
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
|
|
11
|
+
date: 2026-09-08 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: json
|