specguard-rspec 0.2.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.
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "json_schemer"
5
+
6
+ module SpecGuard
7
+ module RSpec
8
+ # The vendored OpenTestIntent schema could not be loaded.
9
+ #
10
+ # Deliberately *not* a {UsageError} even though both map to exit 2: this is
11
+ # the linter being broken, not the caller misusing it, and the message
12
+ # wording follows the reference tool rather than the CLI's own. Keeping the
13
+ # two distinct is what lets the CLI print
14
+ # `error: could not load schema <path>: ...` — byte-parity with
15
+ # `bin/validate-intent:858-862` — for one and `specguard-lint: error: ...`
16
+ # for the other.
17
+ class SchemaError < Error; end
18
+
19
+ # The OpenTestIntent v1 schema, loaded and applied.
20
+ #
21
+ # == Why loading failure is exit 2 and not exit 1
22
+ #
23
+ # The exit contract (SPGD-12 §1) spends `1` on "an annotation is
24
+ # malformed". If a packaging accident leaves the vendored schema out of the
25
+ # gem, the obvious Ruby implementation lets the exception escape and Ruby
26
+ # exits 1 — so CI reports a malformed annotation and a developer goes
27
+ # hunting for a bad annotation that does not exist. The reference tool
28
+ # already ruled on this:
29
+ #
30
+ # except (OSError, json.JSONDecodeError) as exc:
31
+ # print("error: could not load schema %s: %s" % (SCHEMA_PATH, exc), file=sys.stderr)
32
+ # return 2
33
+ # # open-test-intent, bin/validate-intent:858-862
34
+ #
35
+ # Hence {SchemaError}: every way loading can fail is caught here and
36
+ # retyped, so the CLI can map the whole class of them to 2 without
37
+ # inspecting exception classes from three different libraries.
38
+ #
39
+ # The CLI loads this **before** it scans anything, so a broken schema can
40
+ # never produce a run that reports "0 malformed" having validated nothing.
41
+ class Schema
42
+ # @param path [String] the vendored schema
43
+ # @return [Schema]
44
+ # @raise [SchemaError] if it cannot be read, parsed, or compiled
45
+ def self.load(path = SCHEMA_PATH)
46
+ document = ::JSON.parse(File.read(path, encoding: "UTF-8"))
47
+ new(document: document, path: path)
48
+ rescue StandardError => e
49
+ # Intentionally broad. Reading can raise SystemCallError/IOError,
50
+ # parsing JSON::ParserError, and compiling or meta-validating whatever
51
+ # json_schemer decides an unusable schema document deserves. All of
52
+ # them mean the same thing to the caller, and all of them must be 2
53
+ # rather than an uncaught exception's 1.
54
+ raise SchemaError, "could not load schema #{path}: #{e.message}"
55
+ end
56
+
57
+ attr_reader :document, :path
58
+
59
+ def initialize(document:, path: SCHEMA_PATH, renderer: ViolationRenderer.new)
60
+ @document = document
61
+ @path = path
62
+ @renderer = renderer
63
+ @schemer = JSONSchemer.schema(document)
64
+ reject_unusable_schema!
65
+ end
66
+
67
+ # @param intent [Object] one parsed annotation
68
+ # @return [Array<String>] reason lines in the reference tool's grammar
69
+ # and order; empty means the annotation is valid
70
+ #
71
+ # The **validator** decides the verdict; the renderer only decides the
72
+ # wording. Deriving "is this annotation valid?" from "could we phrase a
73
+ # sentence about it?" would make an unphrasable violation and a clean
74
+ # annotation the same state — and under {Linter} that state is a green
75
+ # run, which is this project's signature vacuous green (KB SPGD-78)
76
+ # arriving through the one door the whole rendering strategy exists to
77
+ # close. The gemspec pins `~> 2.5`, which admits 2.6 and beyond; the
78
+ # renderer is built from structured fields precisely so a bump degrades
79
+ # to *wrong-looking text* rather than to silence, and this is what keeps
80
+ # that promise true for error shapes it has never seen.
81
+ def violations(intent)
82
+ errors = @schemer.validate(intent).to_a
83
+ return [] if errors.empty?
84
+
85
+ rendered = @renderer.render(errors, intent)
86
+ rendered.empty? ? errors.map { |error| unrenderable_reason(error) } : rendered
87
+ end
88
+
89
+ private
90
+
91
+ # Last resort: json_schemer's own sentence, and failing even that, the
92
+ # error's structure. Both are worse output than the reference grammar.
93
+ # Both are enormously better than reporting the annotation as clean.
94
+ def unrenderable_reason(error)
95
+ error["error"] || "does not match the schema at #{error['schema_pointer']}"
96
+ end
97
+
98
+ # Checks the vendored document against draft-07's own meta-schema before
99
+ # anything is validated against it.
100
+ #
101
+ # This is not belt-and-braces, it closes a real hole. json_schemer is
102
+ # permissive about nonsense in a *schema*: given `{"type": 42}` it
103
+ # compiles happily and then treats the keyword as absent — so a schema
104
+ # file corrupted in a way that still parses as JSON produces a linter
105
+ # that **accepts every annotation** and reports a clean run. That is this
106
+ # project's signature vacuous-green defect (KB SPGD-78) arriving through
107
+ # the back door, and it is worse than a crash because it looks like
108
+ # success.
109
+ #
110
+ # Some malformations are lazier still — `{"required": "nope"}` compiles,
111
+ # passes nothing, and raises NoMethodError from inside the first
112
+ # `#validate`. Meta-validating here converts that whole family into a
113
+ # SchemaError at load time, which the CLI reports as
114
+ # `error: could not load schema ...` and exits 2 on.
115
+ def reject_unusable_schema!
116
+ return if @schemer.valid_schema?
117
+
118
+ raise Error, "not a valid draft-07 schema (#{@schemer.validate_schema.first&.fetch('error')})"
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://specguard.dev/schemas/open-test-intent.v1.json",
4
+ "title": "OpenTestIntent v1",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {
8
+ "entity": { "type": "string", "minLength": 2 },
9
+ "action": { "type": "string", "minLength": 2 },
10
+ "behavior": { "type": "string", "minLength": 15 },
11
+ "layer": { "type": "string", "enum": ["unit", "integration", "request", "system"] },
12
+ "preconditions": { "type": "array", "items": { "type": "string" } }
13
+ },
14
+ "required": ["entity", "action", "behavior", "layer"]
15
+ }
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require "zlib"
7
+
8
+ require_relative "configuration"
9
+ require_relative "version"
10
+
11
+ module SpecGuard
12
+ module RSpec
13
+ # The one HTTP call this gem makes: `POST <endpoint>/api/v1/ingest`,
14
+ # carrying a whole run.
15
+ #
16
+ # == Why this returns a result instead of raising
17
+ #
18
+ # {SpecGuard::RSpecFormatter}'s never-block-CI guard is a `rescue` around
19
+ # each hook, and a `rescue` is structurally blind to the failure that
20
+ # matters most here: `Net::HTTP` hands back `Net::HTTPUnauthorized` as an
21
+ # ordinary return value. A wrong API key therefore raises nothing, warns
22
+ # nothing and logs nothing — the run's telemetry disappears in complete
23
+ # silence, which is precisely the outcome the client-gem spec's "if the API
24
+ # key is wrong … it logs a warning to stderr" forbids.
25
+ #
26
+ # So the two failure families are made the same *shape* rather than left to
27
+ # two different mechanisms: {#deliver} answers a {Result} for a non-2xx
28
+ # response and a {Result} for a raised exception, and the caller has one
29
+ # thing to check. Nothing escapes this class except `Interrupt`,
30
+ # `SignalException` and `SystemExit` — Ctrl-C must stay Ctrl-C.
31
+ #
32
+ # == One request per process, gzipped once it is big enough
33
+ #
34
+ # A run goes in a single POST, and the body is compressed above a size
35
+ # threshold. Both halves are decisions rather than defaults, and the
36
+ # roadmap asked for them to be made deliberately rather than discovered in
37
+ # production, so they are written down here.
38
+ #
39
+ # === Batching: no, and this is settled
40
+ #
41
+ # * `Ingest::Payload` derives `total_specs_count` from the specs of *that*
42
+ # request. Splitting a run across N POSTs with no way to say they are
43
+ # one run would produce N `TestRun` rows with a split denominator,
44
+ # corrupting the headline annotated-ratio metric.
45
+ # * That is qualified rather than absolute, and the qualification is
46
+ # `ci_run_id` + `shard_id`. The platform folds every POST carrying the
47
+ # same run id onto one `TestRun`, keyed by shard so a slice that arrives
48
+ # twice replaces itself, which is what makes a *sharded* run — N
49
+ # processes, N POSTs, one run — land as one row. So the rule this class
50
+ # keeps is narrower than it was: **one process sends one request.**
51
+ # * Batching a single process's own run into several POSTs stays wrong,
52
+ # and not only for the denominator: every part would carry the same
53
+ # `shard_id`, so the parts would overwrite one another and the row would
54
+ # keep only the last. Fixing that means a new part-of-a-shard concept on
55
+ # the platform, which is a schema change bought to solve a problem that
56
+ # compression already solved.
57
+ #
58
+ # === Streaming: no, for the same reason, and the reason is measured
59
+ #
60
+ # The pressure that made batching and streaming look necessary was size,
61
+ # and size is now a number rather than a worry. Measured by running a real
62
+ # 200-file / 20,000-example suite through this gem's own formatter, half of
63
+ # the examples annotated:
64
+ #
65
+ # identity 7,354,782 bytes 7.01 MiB needs 5.9 Mbit/s to write in 10s
66
+ # gzip 346,206 bytes 0.33 MiB needs 0.3 Mbit/s
67
+ # ratio 21.2x 95.3% saved 60 ms to compress
68
+ #
69
+ # Uncompressed, that body has to be *written* inside
70
+ # `Configuration::DEFAULT_TIMEOUT_SECONDS` (10). At 5 Mbit/s of uplink it
71
+ # takes 11.8s and fails as a `write_timeout`; the run then lands in
72
+ # `log/test_results.jsonl` and the platform never sees it. Compressed, the
73
+ # same run takes 0.55s on that link and 2.8s on a 1 Mbit/s one. The 60 ms
74
+ # spent compressing is noise against a suite that took minutes.
75
+ #
76
+ # Two honesties about that ratio. It is *below* the 35x this change was
77
+ # proposed on, because SPGD-159 has since added `id` and `spec_file_path`
78
+ # to every row — re-measure rather than quote, is the lesson. And it is
79
+ # probably *above* what a real suite gets: synthetic example names repeat
80
+ # more than human ones do, so treat 21x as the optimistic end. Nothing here
81
+ # depends on the exact figure. Even a pessimistic 5x moves the 20k case
82
+ # from "cannot ship on a slow link" to "ships with room to spare", and
83
+ # 0.33 MiB is not a payload anyone needs to chunk.
84
+ #
85
+ # So: compression yes, batching and streaming no. Recorded rather than left
86
+ # implicit — an undocumented decision is one that gets re-opened in six
87
+ # months by someone who cannot tell it was ever made.
88
+ #
89
+ # === Why a threshold rather than always
90
+ #
91
+ # Below {GZIP_THRESHOLD_BYTES} the round trip buys nothing worth paying for
92
+ # in opacity: a small run stays identity-encoded, so it is still readable
93
+ # with `curl` and `tcpdump`, and the local-file and stub-server paths still
94
+ # show a human a JSON body. Compression is for the case that could not ship
95
+ # at all, not a uniform policy.
96
+ #
97
+ # Compression also sits *inside* the never-block-CI contract. A `Zlib`
98
+ # failure falls back to the identity body — which the platform still
99
+ # accepts — rather than raising: a run must not be lost to an optimisation.
100
+ #
101
+ # === The version floor this creates, which is a deploy order and not a merge order
102
+ #
103
+ # Sending `Content-Encoding: gzip` requires a platform that can inflate one,
104
+ # and that arrived with `GzipRequestBody` (SPGD-175). "Merge the platform PR
105
+ # first" is the half of this that is easy to say and is *not* sufficient:
106
+ # merge order only settles the source trees. What this gem actually talks to
107
+ # is a **deployment**.
108
+ #
109
+ # So a gem at this version pointed at an installation deployed before
110
+ # `GzipRequestBody` will 400 every run over {GZIP_THRESHOLD_BYTES} — the
111
+ # inflater is not there, the body reaches the JSON parser still gzipped, and
112
+ # `Api::V1::IngestsController` refuses it. {#deliver} answers
113
+ # `Result(outcome: :rejected, code: 400)`; there is no retry-as-identity, so
114
+ # the formatter falls back to `log/test_results.jsonl` and the run is lost
115
+ # to the platform. That is precisely the failure this change exists to
116
+ # close, reintroduced for precisely the large suites it targets — and it is
117
+ # silent apart from one stderr line, because CI still goes green.
118
+ #
119
+ # Written down rather than fixed, deliberately. A 400-triggered retry with
120
+ # the identity body would paper over it, but it doubles the request count on
121
+ # every genuinely-malformed payload and makes a client-side bug look like a
122
+ # flake; that is a contract decision, not a detail to slip in here. The
123
+ # honest statement of the constraint is a version floor: **this gem requires
124
+ # a platform deployment that includes `GzipRequestBody`.**
125
+ class Transport
126
+ # `config/routes.rb` mounts `post "ingest"` under the `/api/v1` scope.
127
+ # Part of the platform's contract, so it is not configurable — the
128
+ # `endpoint` setting is the installation's address and nothing more.
129
+ PATH = "/api/v1/ingest"
130
+ CONTENT_TYPE = "application/json"
131
+ USER_AGENT = "specguard-rspec/#{SpecGuard::RSpec::VERSION}"
132
+ CONTENT_ENCODING = "gzip"
133
+
134
+ # Bodies at least this large are gzipped; smaller ones go identity. See
135
+ # the class comment for why this is a threshold and not a switch.
136
+ #
137
+ # 256 KiB is roughly where the round trip starts paying — a few thousand
138
+ # examples. It is a judgement call, not a measured optimum, and the only
139
+ # thing that depends on the exact number is how large a run has to be
140
+ # before `curl` stops showing you a readable body.
141
+ GZIP_THRESHOLD_BYTES = 256 * 1024
142
+
143
+ # What the ingest endpoint said, in the one form the caller has to handle.
144
+ #
145
+ # `outcome` is one of:
146
+ #
147
+ # :success a 2xx. `code` carries it (202 in the happy path).
148
+ # :rejected a non-2xx. `code` carries it; nothing was raised.
149
+ # :failed an exception was raised. `error` carries it.
150
+ Result = Struct.new(:outcome, :code, :error, keyword_init: true) do
151
+ # The status codes worth spelling out, because each implies a different
152
+ # thing for the reader to *do*. A 401 means "rotate or fix the key"; a
153
+ # 400 means "the payload this gem built was refused", which is a bug
154
+ # report and not a credentials problem. Printing a bare number would
155
+ # leave a CI operator to guess which of the two they are looking at.
156
+ ADVICE = {
157
+ 400 => "the endpoint rejected the payload",
158
+ 401 => "the API key was not accepted",
159
+ 403 => "this API key may not write to that repository",
160
+ 404 => "no ingest endpoint at that URL — check SPECGUARD_ENDPOINT",
161
+ 413 => "the payload was too large for the endpoint",
162
+ 429 => "rate limited by the endpoint"
163
+ }.freeze
164
+
165
+ def success? = outcome == :success
166
+
167
+ # A single clause naming what went wrong, for the one stderr line a run
168
+ # is allowed. `nil` on success, because there is nothing to say.
169
+ #
170
+ # @return [String, nil]
171
+ def reason
172
+ case outcome
173
+ when :success then nil
174
+ when :rejected then [+"HTTP #{code}", ADVICE[code]].compact.join(" — ")
175
+ else "#{error.class}: #{error.message}"
176
+ end
177
+ end
178
+ end
179
+
180
+ # @param endpoint [String, nil] the installation's base URL. Any trailing
181
+ # slashes are dropped; a path prefix is preserved, so an installation
182
+ # behind `https://tools.example.com/specguard` works.
183
+ # @param api_key [String, nil] sent verbatim as a Bearer token.
184
+ # @param timeout [Numeric, String, nil] seconds. Anything that is not a
185
+ # positive finite number falls back to the default rather than raising:
186
+ # a typo in `SPECGUARD_TIMEOUT` must not be able to fail a suite.
187
+ def initialize(endpoint:, api_key:, timeout: Configuration::DEFAULT_TIMEOUT_SECONDS)
188
+ @endpoint = endpoint
189
+ @api_key = api_key
190
+ @timeout = sanitize_timeout(timeout)
191
+ end
192
+
193
+ attr_reader :timeout
194
+
195
+ # Where this transport would POST.
196
+ #
197
+ # @return [URI::HTTP]
198
+ # @raise [ArgumentError] when the endpoint is missing or is not an
199
+ # http(s) URL. Raised rather than returned because {#deliver} converts
200
+ # it into a {Result} like every other failure, and a caller asking for
201
+ # the URI directly wants to know.
202
+ def uri
203
+ @uri ||= build_uri
204
+ end
205
+
206
+ # @param payload [Hash] the run, as {SpecGuard::RSpecFormatter#payload}
207
+ # assembles it. Sent as-is: its key names are already the platform's
208
+ # ingest contract, and reshaping it here would put the wire format two
209
+ # files away from the code that decides it.
210
+ # @return [Result] never nil, never raised through.
211
+ def deliver(payload)
212
+ response = post(JSON.generate(payload))
213
+ code = response.code.to_i
214
+
215
+ return Result.new(outcome: :success, code: code) if response.is_a?(Net::HTTPSuccess)
216
+
217
+ Result.new(outcome: :rejected, code: code)
218
+ rescue ScriptError, StandardError => e
219
+ # Connection refused, DNS failure, TLS failure, open/read timeout, a
220
+ # malformed endpoint — one family, one shape. `ScriptError` is in the
221
+ # list for the same reason the formatter's guard names it: an autoload
222
+ # blowing up under `net/http` is not a `StandardError`, and a bare
223
+ # rescue would let it escape and take the suite's exit code with it.
224
+ Result.new(outcome: :failed, error: e)
225
+ end
226
+
227
+ private
228
+
229
+ def post(body)
230
+ target = uri
231
+
232
+ http = Net::HTTP.new(target.host, target.port)
233
+ http.use_ssl = target.scheme == "https"
234
+ # All three, not just the two the spec names. Compression takes a
235
+ # 20k-example run from 7.01 MiB to 0.33 MiB, which is what puts the
236
+ # write comfortably inside the budget rather than past it — but a peer
237
+ # that accepts the connection and then stops reading still hangs in the
238
+ # *write* whatever the body's size, so the timeout is the thing that
239
+ # bounds it. Same unbounded wait, reached by a different door.
240
+ http.open_timeout = @timeout
241
+ http.read_timeout = @timeout
242
+ http.write_timeout = @timeout
243
+
244
+ http.start { |connection| connection.request(build_request(target, body)) }
245
+ end
246
+
247
+ def build_request(target, body)
248
+ request = Net::HTTP::Post.new(target.request_uri)
249
+ # `Api::BaseController#bearer_token` matches /\ABearer\s+(?<token>.+)\z/i.
250
+ request["Authorization"] = "Bearer #{@api_key}"
251
+ # Describes the body *inside* any encoding, which is what
252
+ # `Content-Type` means — the platform's `GzipRequestBody` inflates and
253
+ # then hands an ordinary JSON request downstream.
254
+ request["Content-Type"] = CONTENT_TYPE
255
+ request["Accept"] = CONTENT_TYPE
256
+ request["User-Agent"] = USER_AGENT
257
+
258
+ compressed = compress(body)
259
+ request["Content-Encoding"] = CONTENT_ENCODING if compressed
260
+ # `Net::HTTP::Post#body=` sets `Content-Length` from what it is given,
261
+ # so the length always describes the bytes actually on the wire.
262
+ request.body = compressed || body
263
+ request
264
+ end
265
+
266
+ # The compressed body, or `nil` to mean "send it as it is" — for a run
267
+ # under the threshold and, deliberately, for a compression that failed.
268
+ #
269
+ # Returning `nil` on failure rather than letting it out is the whole
270
+ # point: an identity body is something the platform accepts, so a broken
271
+ # `Zlib` costs a large run some bandwidth and nothing else. Letting the
272
+ # error reach {#deliver} would turn it into `Result(outcome: :failed)`
273
+ # and lose the run to a *saving*.
274
+ #
275
+ # `ScriptError, StandardError` matches {#deliver}'s own guard rather than
276
+ # naming `Zlib::Error`: the failure worth catching here is as likely to be
277
+ # the `zlib` extension missing from a stripped-down Ruby, which is a
278
+ # `LoadError` and so a `ScriptError`, as it is a compression fault.
279
+ def compress(body)
280
+ return nil if body.bytesize < GZIP_THRESHOLD_BYTES
281
+
282
+ Zlib.gzip(body)
283
+ rescue ScriptError, StandardError
284
+ nil
285
+ end
286
+
287
+ def build_uri
288
+ base = @endpoint.to_s.strip
289
+ raise ArgumentError, "no endpoint is configured (set SPECGUARD_ENDPOINT)" if base.empty?
290
+
291
+ parsed = URI.parse(base.sub(%r{/+\z}, "") + PATH)
292
+ # `URI::HTTPS < URI::HTTP`, so this admits both and rejects the
293
+ # scheme-less `specguard.example.com` that `URI.parse` happily returns
294
+ # as a `URI::Generic` with a nil host — which `Net::HTTP` would then
295
+ # try to connect to.
296
+ raise ArgumentError, "endpoint must be an http:// or https:// URL, got #{base.inspect}" unless
297
+ parsed.is_a?(URI::HTTP) && !parsed.host.to_s.empty?
298
+
299
+ parsed
300
+ end
301
+
302
+ def sanitize_timeout(value)
303
+ parsed = Float(value, exception: false)
304
+ return Configuration::DEFAULT_TIMEOUT_SECONDS unless parsed&.finite? && parsed.positive?
305
+
306
+ parsed
307
+ end
308
+ end
309
+ end
310
+ end