specguard-ruby 0.3.1

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.
data/Rakefile ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ # The fixtures under spec/fixtures/ are linter *input* that happens to be named
7
+ # `*_spec.rb` — exactly what RSpec's default pattern loads. `.rspec` excludes
8
+ # them, and that is the source of truth for a bare `rspec` run.
9
+ #
10
+ # The rake task has to repeat it. RSpec::Core::RakeTask always puts an explicit
11
+ # `--pattern` on the command line, and a command-line `--pattern` discards the
12
+ # `--exclude-pattern` that came from `.rspec` — so the stock task loads the
13
+ # fixtures as examples and the suite dies with
14
+ # "cannot load such file -- rails_helper". Passing the exclusion on the command
15
+ # line too is what makes the two invocations agree. (Clearing `t.pattern`
16
+ # instead does not work: the task then passes an empty argument and matches
17
+ # nothing.)
18
+ #
19
+ # Keep this in step with `.rspec` — `rake` and `rspec` must select the same
20
+ # files. spec/spec_helper.rb fails the suite loudly if a fixture ever does get
21
+ # loaded as an example.
22
+ RSpec::Core::RakeTask.new(:spec) do |t|
23
+ t.rspec_opts = %(--exclude-pattern "spec/fixtures/**/*_spec.rb")
24
+ end
25
+
26
+ # `rake` with no arguments used to do nothing at all, which made it a useless
27
+ # CI entrypoint. It now means "run the suite".
28
+ task default: %i[spec]
Binary file
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # specguard-ingest — replay a saved run through SpecGuard's ingest endpoint.
5
+ #
6
+ # The RSpec formatter writes each undelivered run to `log/test_results.jsonl`,
7
+ # one whole run per line and byte-for-byte the body the endpoint was offered.
8
+ # This is the command that sends it: fix the rotated key, then replay the run
9
+ # the suite already finished, instead of re-running the suite.
10
+ #
11
+ # Exit codes are the contract, and they are `specguard-lint`'s reasoning
12
+ # transferred (see SpecGuard::RSpec::IngestCLI):
13
+ #
14
+ # 0 every line was accepted
15
+ # 1 at least one line was refused by the endpoint
16
+ # 2 this tool could not do its job — misuse, or the tool itself broken
17
+ #
18
+ # SpecGuard::RSpec::IngestCLI#run is written to *return* one of those three and
19
+ # never to raise. The `require`s below are the one thing outside its reach, so
20
+ # they get the same treatment here: a gem that cannot even load is the tool
21
+ # being broken, which is a 2. Left bare, Ruby would exit 1 and report that the
22
+ # platform refused a run it was never offered.
23
+
24
+ # Allow running from a source checkout without bundler by putting the local
25
+ # lib/ on the load path. When the gem is installed (or run under
26
+ # `bundle exec`), specguard/rspec resolves through the normal load path.
27
+ source_lib = File.expand_path("../lib", __dir__)
28
+ $LOAD_PATH.unshift(source_lib) unless $LOAD_PATH.include?(source_lib)
29
+
30
+ begin
31
+ require "specguard/rspec"
32
+ # By its own path, deliberately. `net/http`, `uri` and `zlib` are not on the
33
+ # linter's load chain and this command is the reason to pay for them.
34
+ require "specguard/rspec/ingest_cli"
35
+ rescue ScriptError, StandardError => e
36
+ warn "specguard-ingest: error: could not load specguard-ruby: #{e.class}: #{e.message}"
37
+ exit 2
38
+ end
39
+
40
+ exit SpecGuard::RSpec::IngestCLI.new.run(ARGV)
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # specguard-lint — the SpecGuard annotation linter entrypoint.
5
+ #
6
+ # Selects spec files, finds every `@intent:` annotation in them, validates each
7
+ # against the vendored OpenTestIntent schema, and reports every violation. Exit
8
+ # codes are the contract (SPGD-12 §1):
9
+ #
10
+ # 0 all annotations valid, or there were none ("lint, don't require")
11
+ # 1 one or more annotations are malformed
12
+ # 2 the linter could not do its job — misuse, or the tool itself broken
13
+ #
14
+ # SpecGuard::RSpec::CLI#run is written to *return* one of those three and never
15
+ # to raise; see its class comment for why that takes deliberate effort in Ruby.
16
+ # The `require` below is the one thing outside its reach, so it gets the same
17
+ # treatment here: a gem that cannot even load is the linter being broken, which
18
+ # is a 2. Left bare, Ruby would exit 1 and CI would report a malformed
19
+ # annotation nobody wrote.
20
+
21
+ # Allow running from a source checkout without bundler by putting the local
22
+ # lib/ on the load path. When the gem is installed (or run under
23
+ # `bundle exec`), specguard/rspec resolves through the normal load path.
24
+ source_lib = File.expand_path("../lib", __dir__)
25
+ $LOAD_PATH.unshift(source_lib) unless $LOAD_PATH.include?(source_lib)
26
+
27
+ begin
28
+ require "specguard/rspec"
29
+ rescue ScriptError, StandardError => e
30
+ warn "specguard-lint: error: could not load specguard-rspec: #{e.class}: #{e.message}"
31
+ exit 2
32
+ end
33
+
34
+ exit SpecGuard::RSpec::CLI.new.run(ARGV)
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Minitest's plugin discovery point. Minitest loads every `minitest/*_plugin.rb`
4
+ # it can find on the load path (`Minitest.load_plugins` → `Gem.find_files`), so
5
+ # a gem providing this file needs no require in the consumer's spec files —
6
+ # putting `gem "specguard-ruby"` in the Gemfile is the whole integration.
7
+ #
8
+ # This file exists to be FOUND; the reporter itself lives at
9
+ # `specguard/minitest/reporter` so it can be required explicitly (the
10
+ # deterministic path CI should use, for the same reason the RSpec formatter is
11
+ # registered by flags rather than magic: what posts your telemetry should be
12
+ # visible in the command that ran the tests).
13
+ #
14
+ # The hook name `plugin_specguard_init` is Minitest's convention: everything
15
+ # between `plugin_` and `_init` is the plugin's name. `Minitest.reporter` is
16
+ # the live CompositeReporter at this point — Minitest assigns it before
17
+ # initializing plugins precisely so plugins can append to it — and appending
18
+ # keeps the default Summary/Progress reporters untouched, so the suite's own
19
+ # output looks exactly as it did without telemetry.
20
+ module Minitest
21
+ def self.plugin_specguard_init(_options)
22
+ require "specguard/minitest/reporter"
23
+ reporter << SpecGuard::Minitest::Reporter.new
24
+ end
25
+ end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ # Configuration and Transport are the framework-free halves of the client
7
+ # (env resolution, wire delivery, the never-raise contract); both live under
8
+ # the rspec namespace because rspec shipped first, neither contains any RSpec.
9
+ # Required here rather than assumed because nothing else loads them on a
10
+ # minitest-only consumer's machine.
11
+ require_relative "../rspec/configuration"
12
+
13
+ # The Minitest half of `specguard-ruby`. Everything the RSpec formatter knows
14
+ # about the platform — the envelope's field names, the transport's
15
+ # never-raise contract, the switch that a missing key is — is framework-free
16
+ # knowledge that already lives in `SpecGuard::RSpec::Configuration` and
17
+ # `SpecGuard::RSpec::Transport` (both named for the framework that happened to
18
+ # ship first, neither containing any RSpec). This adapter contributes only the
19
+ # part that is genuinely Minitest's: turning `Minitest::Result` objects into
20
+ # the same row shape the RSpec formatter emits, at the moment Minitest hands
21
+ # them to a reporter.
22
+ #
23
+ # It is a duck-typed Minitest reporter (`start` / `record` / `report` /
24
+ # `passed?`) rather than a subclass of `Minitest::AbstractReporter`, because
25
+ # the composite only ever calls those four and a superclass would add
26
+ # assertions about documentation we do not need.
27
+ #
28
+ # ## Telemetry never fails the suite
29
+ #
30
+ # The same guarantee the RSpec formatter gives, arrived at the same way: every
31
+ # step is wrapped, a failed delivery costs one line on stderr and a line in the
32
+ # local sink, and `#passed?` is a constant `true` because Minitest folds it
33
+ # into the run's own verdict via `CompositeReporter#passed?` — `all?` over its
34
+ # reporters — and a telemetry reporter that could redden a suite would be a
35
+ # telemetry reporter somebody deletes.
36
+ module SpecGuard
37
+ module Minitest
38
+ class Reporter
39
+ # Rows with no usable location are dropped rather than mangled: a row
40
+ # whose file cannot be named fits no by-file aggregate on the platform
41
+ # and would surface as noise. The RSpec formatter's `capture` makes the
42
+ # equivalent call about a metadata-less example.
43
+ class << self
44
+ # `Dir.pwd` at load time, not per row: a reporter constructed inside a
45
+ # test that changes the working directory would otherwise relativize
46
+ # one suite's rows against two different roots.
47
+ def repo_root
48
+ Dir.pwd
49
+ end
50
+ end
51
+
52
+ def initialize(configuration: SpecGuard::RSpec.configuration,
53
+ transport: nil, output: $stderr,
54
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
55
+ @configuration = configuration
56
+ # Injected for the specs; the real thing is built the same way the
57
+ # RSpec formatter builds it, from the same configuration object.
58
+ @transport = transport
59
+ @output = output
60
+ @clock = clock
61
+ @rows = []
62
+ @started_at = nil
63
+ @warned = false
64
+ end
65
+
66
+ def start
67
+ @started_at = @clock.call
68
+ end
69
+
70
+ # Minitest's `CompositeReporter` calls this before every runnable, and
71
+ # minitest 6 requires it on every reporter in the composite — a duck
72
+ # type without it is not "a reporter that ignores prerecords", it is a
73
+ # crash inside `Runnable#run` that takes the suite's exit code with it,
74
+ # which is the one thing this class may never do. A no-op records
75
+ # nothing: the row this adapter wants is the finished `Result`, not the
76
+ # promise of one.
77
+ def prerecord(_klass, _name); end
78
+
79
+ # One `Minitest::Result` in, one wire row out. Guarded rather than
80
+ # trusted for the same reason the RSpec formatter guards `capture`:
81
+ # nothing a result object does while being read may cost the suite its
82
+ # exit code.
83
+ def record(result)
84
+ row = row_for(result)
85
+ @rows << row if row
86
+ rescue ScriptError, StandardError
87
+ nil
88
+ end
89
+
90
+ # Minitest calls this after every runnable has reported, on the same
91
+ # reporter — the delivery point. A run that recorded nothing ships
92
+ # nothing, mirroring the RSpec formatter's stance on an empty suite.
93
+ def report
94
+ return if @rows.empty?
95
+
96
+ deliver(payload)
97
+ rescue ScriptError, StandardError => e
98
+ warn_once("could not ship test telemetry (#{e.class}: #{e.message}). The test run is unaffected.")
99
+ end
100
+
101
+ # Constant by contract — see the class comment. Public because
102
+ # `CompositeReporter#passed?` is `all?` over its reporters and this one
103
+ # must never be the reporter that flips a green suite red.
104
+ def passed?
105
+ true
106
+ end
107
+
108
+ private
109
+
110
+ # The envelope, in the platform's own field names (`Ingest::Payload`:
111
+ # commit_sha / branch / ci_run_id / duration_seconds / specs) — the same
112
+ # rule the RSpec formatter states at its `#payload`: this gem's settings
113
+ # are named its way, every key in this Hash is named the platform's way,
114
+ # and nothing translates between them.
115
+ def payload
116
+ {
117
+ "commit_sha" => @configuration.commit_sha,
118
+ "branch" => @configuration.branch,
119
+ "ci_run_id" => @configuration.run_id,
120
+ "shard_id" => @configuration.shard_id,
121
+ "duration_seconds" => duration_seconds,
122
+ "specs" => @rows
123
+ }
124
+ end
125
+
126
+ # The delivery ladder, in the RSpec formatter's order, with the same two
127
+ # sinks for the same two reasons: no key means the local development
128
+ # record (`local_output_path` — a note that a run happened, not a queue);
129
+ # a refused or failed delivery means the replay queue (`output_path` —
130
+ # the file `specguard-ingest` re-delivers). A blank commit_sha means the
131
+ # platform would refuse the whole run, so it is caught before the wall
132
+ # clock is spent and the run goes to the replay queue. Anything else goes
133
+ # to the transport, whose own contract is that nothing escapes it — a
134
+ # non-success `Result` costs one stderr line and a queued run.
135
+ def deliver(data)
136
+ return append(data, @configuration.local_output_path) if @configuration.api_key.to_s.strip.empty?
137
+
138
+ if @configuration.commit_sha.to_s.strip.empty?
139
+ return fall_back(data, "no commit sha could be resolved (set SPECGUARD_COMMIT_SHA)")
140
+ end
141
+
142
+ result = (@transport || transport_for).deliver(data)
143
+ return if result.success?
144
+
145
+ # The transport's `reason` is already the operator-facing sentence for
146
+ # both a refusal (status, what to do about it, the platform's own
147
+ # words) and a failure (exception class and message) — nothing to add.
148
+ fall_back(data, result.reason)
149
+ end
150
+
151
+ def transport_for
152
+ require_relative "../rspec/transport"
153
+ SpecGuard::RSpec::Transport.new(
154
+ endpoint: @configuration.endpoint,
155
+ api_key: @configuration.api_key,
156
+ timeout: @configuration.timeout
157
+ )
158
+ end
159
+
160
+ def row_for(result)
161
+ # `source_location` is an Array `["path", line]` on minitest 6 and a
162
+ # "path:line" String on minitest 5 — both are read, because which one
163
+ # a consumer runs is not this gem's to choose.
164
+ location = result.source_location
165
+ file, line =
166
+ if location.is_a?(Array)
167
+ location
168
+ else
169
+ location.to_s.split(":", 2)
170
+ end
171
+ # A row without both halves of a location fits no by-file aggregate
172
+ # and no stable identity; dropped rather than mangled, the same call
173
+ # the RSpec formatter makes about a metadata-less example.
174
+ return nil if file.to_s.strip.empty? || line.to_i.zero?
175
+
176
+ file = relative_path(file)
177
+ name = "#{result.klass}##{result.name}"
178
+ {
179
+ # 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).
183
+ "id" => "#{file}:#{line}",
184
+ "spec_file_path" => file,
185
+ "file_path" => file,
186
+ "line_number" => line&.to_i,
187
+ "name" => name,
188
+ "duration" => result.time,
189
+ # The platform's three outcomes, mapped from Minitest's questions:
190
+ # a skip is a pending (the suite's own vocabulary for "deliberately
191
+ # not answered"), and a failure and an error are both "failed" —
192
+ # Minitest distinguishes assertion failures from exceptions, the
193
+ # wire contract does not, and inventing a fourth outcome here would
194
+ # be a lie the schema refuses.
195
+ "outcome" => outcome_for(result)
196
+ }
197
+ end
198
+
199
+ def outcome_for(result)
200
+ return "pending" if result.skipped?
201
+ return "failed" unless result.passed?
202
+
203
+ "passed"
204
+ end
205
+
206
+ def relative_path(path)
207
+ root = self.class.repo_root
208
+ return path unless path.start_with?("#{root}/")
209
+
210
+ path.delete_prefix("#{root}/")
211
+ end
212
+
213
+ def duration_seconds
214
+ return 0.0 if @started_at.nil?
215
+
216
+ (@clock.call - @started_at).round(3)
217
+ end
218
+
219
+ # One JSON run per line, to whichever file this delivery's outcome
220
+ # warrants (see `#deliver` for which and why).
221
+ def append(data, path)
222
+ FileUtils.mkdir_p(File.dirname(path))
223
+ File.open(path, "a") { |f| f.puts(JSON.generate(data)) }
224
+ rescue ScriptError, StandardError => e
225
+ warn_once("could not write telemetry to #{path} (#{e.class}: #{e.message}). The test run is unaffected.")
226
+ end
227
+
228
+ def fall_back(data, reason)
229
+ warn_once("could not deliver test telemetry (#{reason}). Falling back to the replay queue. " \
230
+ "The test run is unaffected.")
231
+ append(data, @configuration.output_path)
232
+ end
233
+
234
+ # Once per process, like the formatter's `warn_once`: fifty failing
235
+ # deliveries is one problem, not fifty lines of it.
236
+ def warn_once(message)
237
+ return if @warned
238
+
239
+ @warned = true
240
+ @output.puts("SpecGuard: #{message}")
241
+ end
242
+ end
243
+ end
244
+ end