specguard-ruby 0.3.29 → 0.3.31

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: 887fc6d71732b1947cc48c34f27c68ac5b9b71c729f604cf95c41e508cc8d6af
4
- data.tar.gz: e2f20dbc68a7324cc34381e204bc8ac0f5db262002d86ffc3bd24c8c2cfb3663
3
+ metadata.gz: 14818808e8ef3b696b1b3834ea590734fa398f5fabbef1c5646d03699e844d99
4
+ data.tar.gz: ccce2e2cf09f6b3401284c0f7f3f343185c86fa2d48bc43ca7bbd9e48575910e
5
5
  SHA512:
6
- metadata.gz: e3d18c2b2d19c8d25b0c5a9277847767996ff27be06f7f5802b052c7a4dab7f20e7244214a339fc0a4bc0343103572ad842bd381f95ccd6efa670422da445edc
7
- data.tar.gz: 9a2b14e04e71e6e957134e29ad38f11fdaad0aede17893907a048d9e6414d93affb7844f4a99e630194f5095c0bc945e271800871f39f71f325ed8b4805492ae
6
+ metadata.gz: a7612a5d841ecf6d7b1f675bfe362a5141948c1e227a89d7d90e449c9f3b802b650894e7cd3862bb37a897dcbc77ae6e0d627caa1b3cbde3275b54ebd41beb54
7
+ data.tar.gz: d78f0bea385d4a9ca63d1581648350c76662f7540b0c105dda6a0ebb15c7b3be1be98e7d2ca8f29b5536fac597a4ed155cb5b96f8ff8575e79bfde7292ff0034
data/README.md CHANGED
@@ -44,13 +44,26 @@ be assumed, require it explicitly before the run:
44
44
  bundle exec ruby -rminitest/specguard_plugin -e 'Minitest.extensions << "specguard"; load ARGV[0]' test/your_suite.rb
45
45
  ```
46
46
 
47
+ Configure it from Ruby, if the environment is not the right place, through the framework-neutral
48
+ `SpecGuard.configure` — in a `test_helper.rb`, before the run starts:
49
+
50
+ ```ruby
51
+ SpecGuard.configure do |config|
52
+ config.branch = "release/2.0"
53
+ end
54
+ ```
55
+
56
+ `SpecGuard::RSpec.configure` is the same call under its RSpec-era spelling: both return the one
57
+ configuration object the reporter and the formatter read, so either works from either framework.
58
+ See [Shipping the run to SpecGuard](#shipping-the-run-to-specguard) for the settings.
59
+
47
60
  ## The linter — `specguard-lint`
48
61
 
49
62
  Validates `# @intent:` annotations in changed (or all) `*_spec.rb` files against the OpenTestIntent
50
63
  JSON Schema. Exits `1` on a malformed annotation — or a well-formed but unreachable one (stacked
51
64
  above another comment-form `@intent:` line, so the one-line lookback never claims it; separated
52
- from its `it` by one blank or ordinary comment line, which puts it out of the lookback's reach;
53
- directly above an `it` that carries its own trailing `@intent:`, which extraction prefers, so the
65
+ from its example (`it`, `specify`, `xit`, `fit`, `example`, …) by one blank or ordinary comment line, which puts it out of the lookback's reach;
66
+ directly above an example that carries its own trailing `@intent:`, which extraction prefers, so the
54
67
  comment is shadowed; or trailing on a `describe`/`context` group line, which no example's extraction can ever reach) — and
55
68
  **never** fails on a *missing* one (adoption is opt-in and gradual).
56
69
 
@@ -337,8 +350,8 @@ Every way the backend can fail — the binary is missing, will not execute, exit
337
350
  is not a verdict, or emits output that is not a report — is **exit 2**, the linter's "could not do
338
351
  my job" code. It never becomes exit 1, which means "an annotation is malformed" — or well-formed
339
352
  but unreachable: stacked above another comment-form `@intent:` line, so the one-line lookback
340
- never claims it, separated from its `it` by one blank or ordinary comment line, shadowed by its
341
- `it`'s own trailing `@intent:`, or trailing on a `describe`/`context` group line, which no example's extraction can ever reach — and nothing else.
353
+ never claims it, separated from its example by one blank or ordinary comment line, shadowed by its
354
+ example's own trailing `@intent:`, or trailing on a `describe`/`context` group line, which no example's extraction can ever reach — and nothing else.
342
355
 
343
356
  ## The formatter — `SpecGuard::RSpecFormatter`
344
357
 
@@ -441,8 +454,9 @@ the one on the next line.
441
454
  # SPECGUARD_SHARD_ID / SPECGUARD_OUTPUT_PATH / SPECGUARD_LOCAL_OUTPUT_PATH
442
455
  # override any of it.
443
456
  #
444
- # Assign a value here only when it is one neither source can know:
445
- SpecGuard::RSpec.configure do |config|
457
+ # Assign a value here only when it is one neither source can know
458
+ # (`SpecGuard::RSpec.configure` is the same call, under its RSpec-era name):
459
+ SpecGuard.configure do |config|
446
460
  config.branch = "release/2.0"
447
461
  end
448
462
  ```
@@ -460,9 +474,18 @@ export SPECGUARD_API_KEY=… # from your repository's settings
460
474
  export SPECGUARD_TIMEOUT=10 # optional; seconds, applied to connect and read
461
475
  ```
462
476
 
477
+ **Using an `sga_` agent key? Also set `SPECGUARD_REPOSITORY_ID`.** An agent key
478
+ covers a set of repositories, so the key cannot say which one a run belongs to;
479
+ the request has to. With `SPECGUARD_REPOSITORY_ID=42` the run is POSTed to
480
+ `<endpoint>/api/v1/repositories/42/ingest` instead of `<endpoint>/api/v1/ingest`
481
+ (`config.repository_id` in Ruby). Under a repository `sgk_` key leave it unset —
482
+ if you do set it, the server ignores it. The id may contain only letters, digits,
483
+ `-` and `_`; anything else is refused before a request is made. A `404` with an
484
+ id configured means no repository with that id is available to the key.
485
+
463
486
  ```ruby
464
487
  # ...or in Ruby, if you would rather not use the environment
465
- SpecGuard::RSpec.configure do |config|
488
+ SpecGuard.configure do |config|
466
489
  config.endpoint = "https://specguard.example.com"
467
490
  config.api_key = ENV["SPECGUARD_API_KEY"]
468
491
  config.timeout = 10
@@ -474,7 +497,7 @@ is written to `log/test_results.local.jsonl` — the local development record,
474
497
  kept apart from the replay queue — so local development needs no opt-out, and a
475
498
  fork with no secret configured behaves like a laptop rather than like a broken
476
499
  build. The local file's name is configurable via `SPECGUARD_LOCAL_OUTPUT_PATH`
477
- (or `SpecGuard::RSpec.configure { |c| c.local_output_path = ... }`). The write
500
+ (or `SpecGuard.configure { |c| c.local_output_path = ... }`). The write
478
501
  itself is silent when it succeeds — the ordinary case on any machine that can
479
502
  create `log/` — and it usually does.
480
503
 
@@ -501,6 +524,7 @@ offered to the endpoint and not accepted — so the run can be replayed later wi
501
524
  ```
502
525
  SpecGuard: could not deliver test telemetry (HTTP 401 — the API key was not
503
526
  accepted). Falling back to log/test_results.jsonl; the test run is unaffected.
527
+ Once the delivery is fixed, replay this run with: bundle exec specguard-ingest log/test_results.jsonl
504
528
  ```
505
529
 
506
530
  **That line carries the endpoint's own words when it has any.** A `400` refusal
@@ -513,7 +537,8 @@ SpecGuard: could not deliver test telemetry (HTTP 400 — the endpoint rejected
513
537
  the payload — spec 3 (spec/orders_spec.rb:9): line_number is required and must
514
538
  be a positive integer; spec 7 (spec/orders_spec.rb:31): outcome must be one of
515
539
  passed, failed, pending). Falling back to log/test_results.jsonl; the test run
516
- is unaffected.
540
+ is unaffected. Once the delivery is fixed, replay this run with: bundle exec
541
+ specguard-ingest log/test_results.jsonl
517
542
  ```
518
543
 
519
544
  It stays **one** line whatever comes back. A systemic problem can have the
@@ -614,8 +639,9 @@ specguard-ingest: lines 1, 2 carried ci_run_id 17442 and each came back with
614
639
  test_run_id 41f2c9b8 — the endpoint folded them onto one run
615
640
  ```
616
641
 
617
- It reads the same `SPECGUARD_ENDPOINT`, `SPECGUARD_API_KEY` and
618
- `SPECGUARD_TIMEOUT` the formatter does, and sends each line through the same
642
+ It reads the same `SPECGUARD_ENDPOINT`, `SPECGUARD_API_KEY`, `SPECGUARD_REPOSITORY_ID`
643
+ (for `sga_` agent keys — a replay queue written under one is re-delivered to the same
644
+ repository-scoped route) and `SPECGUARD_TIMEOUT` the formatter does, and sends each line through the same
619
645
  transport — so a run that was refused for a rotated key appears on the platform
620
646
  once the secret is fixed, with the shard folding described in
621
647
  [If you shard your suite](#if-you-shard-your-suite) applying exactly as it would
@@ -1198,9 +1224,11 @@ export SPECGUARD_ENDPOINT=https://specguard.internal.example.com
1198
1224
  nothing any other formatter wrote, is read or forwarded.
1199
1225
  - **No environment.** SpecGuard's own code reads a fixed list of variables and
1200
1226
  no others: the ones named in the envelope table above, which fill
1201
- `commit_sha`, `branch`, `ci_run_id` and `shard_id`; plus four that configure
1227
+ `commit_sha`, `branch`, `ci_run_id` and `shard_id`; plus five that configure
1202
1228
  the gem itself rather than describing your suite — `SPECGUARD_ENDPOINT` (where
1203
- to send the run), `SPECGUARD_OUTPUT_PATH` (where to write the replay queue —
1229
+ to send the run), `SPECGUARD_REPOSITORY_ID` (which repository an `sga_` agent
1230
+ key's run belongs to — sent as a path segment of the request URL, nothing else),
1231
+ `SPECGUARD_OUTPUT_PATH` (where to write the replay queue —
1204
1232
  runs the endpoint refused or could not be reached for), `SPECGUARD_LOCAL_OUTPUT_PATH`
1205
1233
  (where to write the local development record when there is no key),
1206
1234
  `SPECGUARD_TIMEOUT` (how long to wait), and
data/bin/specguard-ingest CHANGED
@@ -9,13 +9,13 @@
9
9
  # the suite already finished, instead of re-running the suite.
10
10
  #
11
11
  # Exit codes are the contract, and they are `specguard-lint`'s reasoning
12
- # transferred (see SpecGuard::RSpec::IngestCLI):
12
+ # transferred (see SpecGuard::Client::IngestCLI):
13
13
  #
14
14
  # 0 every line was accepted
15
15
  # 1 at least one line was refused by the endpoint
16
16
  # 2 this tool could not do its job — misuse, or the tool itself broken
17
17
  #
18
- # SpecGuard::RSpec::IngestCLI#run is written to *return* one of those three and
18
+ # SpecGuard::Client::IngestCLI#run is written to *return* one of those three and
19
19
  # never to raise. The `require`s below are the one thing outside its reach, so
20
20
  # they get the same treatment here: a gem that cannot even load is the tool
21
21
  # being broken, which is a 2. Left bare, Ruby would exit 1 and report that the
@@ -23,18 +23,18 @@
23
23
 
24
24
  # Allow running from a source checkout without bundler by putting the local
25
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.
26
+ # `bundle exec`), specguard/client resolves through the normal load path.
27
27
  source_lib = File.expand_path("../lib", __dir__)
28
28
  $LOAD_PATH.unshift(source_lib) unless $LOAD_PATH.include?(source_lib)
29
29
 
30
30
  begin
31
- require "specguard/rspec"
31
+ require "specguard/client"
32
32
  # By its own path, deliberately. `net/http`, `uri` and `zlib` are not on the
33
33
  # linter's load chain and this command is the reason to pay for them.
34
- require "specguard/rspec/ingest_cli"
34
+ require "specguard/client/ingest_cli"
35
35
  rescue ScriptError, StandardError => e
36
36
  warn "specguard-ingest: error: could not load specguard-ruby: #{e.class}: #{e.message}"
37
37
  exit 2
38
38
  end
39
39
 
40
- exit SpecGuard::RSpec::IngestCLI.new.run(ARGV)
40
+ exit SpecGuard::Client::IngestCLI.new.run(ARGV)
data/bin/specguard-lint CHANGED
@@ -11,7 +11,7 @@
11
11
  # 1 one or more annotations are malformed
12
12
  # 2 the linter could not do its job — misuse, or the tool itself broken
13
13
  #
14
- # SpecGuard::RSpec::CLI#run is written to *return* one of those three and never
14
+ # SpecGuard::Client::CLI#run is written to *return* one of those three and never
15
15
  # to raise; see its class comment for why that takes deliberate effort in Ruby.
16
16
  # The `require` below is the one thing outside its reach, so it gets the same
17
17
  # treatment here: a gem that cannot even load is the linter being broken, which
@@ -20,15 +20,15 @@
20
20
 
21
21
  # Allow running from a source checkout without bundler by putting the local
22
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.
23
+ # `bundle exec`), specguard/client resolves through the normal load path.
24
24
  source_lib = File.expand_path("../lib", __dir__)
25
25
  $LOAD_PATH.unshift(source_lib) unless $LOAD_PATH.include?(source_lib)
26
26
 
27
27
  begin
28
- require "specguard/rspec"
28
+ require "specguard/client"
29
29
  rescue ScriptError, StandardError => e
30
30
  warn "specguard-lint: error: could not load specguard-ruby: #{e.class}: #{e.message}"
31
31
  exit 2
32
32
  end
33
33
 
34
- exit SpecGuard::RSpec::CLI.new.run(ARGV)
34
+ exit SpecGuard::Client::CLI.new.run(ARGV)
@@ -4,7 +4,7 @@
4
4
  # with what?*
5
5
  #
6
6
  # It is the formatter's half of the annotation story. The linter's half already
7
- # exists and is not duplicated here — {SpecGuard::RSpec::Scanner} finds every
7
+ # exists and is not duplicated here — {SpecGuard::Client::Scanner} finds every
8
8
  # `@intent:` in a file, captures its payload string-aware and parses it, and
9
9
  # the `validate-intent` binary decides whether the result is valid. This class
10
10
  # consumes both. Writing a second extractor would guarantee that the tool
@@ -12,14 +12,14 @@
12
12
  # platform eventually disagree about what an annotation *is*.
13
13
  #
14
14
  # Requiring the linter's chain from here is safe in the direction that matters:
15
- # `lib/specguard/rspec.rb` does not require `rspec/core`, so pulling it in from
15
+ # `lib/specguard/client.rb` does not require `rspec/core`, so pulling it in from
16
16
  # the formatter's side keeps `bin/specguard-lint` loadable on a machine with no
17
17
  # RSpec installed. spec/specguard/rspec/formatter_loading_spec.rb pins that in
18
18
  # both directions.
19
- require_relative "../rspec"
19
+ require_relative "../client"
20
20
 
21
21
  module SpecGuard
22
- module RSpec
22
+ module Client
23
23
  # == The lookback rule (SPGD-12 §2)
24
24
  #
25
25
  # `example.metadata[:line_number]` is the line the `it` is on. An annotation
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SpecGuard
4
- module RSpec
4
+ module Client
5
5
  # Finds `@intent:` annotations in test source and captures the object
6
6
  # literal that follows each one.
7
7
  #
@@ -3,7 +3,7 @@
3
3
  require "optparse"
4
4
 
5
5
  module SpecGuard
6
- module RSpec
6
+ module Client
7
7
  # `specguard-lint`'s command line, and the whole of the exit contract.
8
8
  #
9
9
  # == The contract, and the reason it needs defending
@@ -358,7 +358,7 @@ module SpecGuard
358
358
  end
359
359
 
360
360
  # The fence arm of the `:all` empty reason: every matching file the walk
361
- # found was inside a {SpecGuard::RSpec::FileSelector::SKIPPED_DIRECTORIES}
361
+ # found was inside a {SpecGuard::Client::FileSelector::SKIPPED_DIRECTORIES}
362
362
  # directory, so the silence is the fence's, not the tree's.
363
363
  def all_fenced_reason(selection)
364
364
  n = selection.skipped
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SpecGuard
4
- module RSpec
4
+ module Client
5
5
  # Where the checkout is sitting — which commit, and which branch — asked of
6
6
  # git directly.
7
7
  #
@@ -120,7 +120,7 @@ module SpecGuard
120
120
  # The escape hatch stays open on top of all of it, for a value neither
121
121
  # source can know:
122
122
  #
123
- # SpecGuard::RSpec.configure do |config|
123
+ # SpecGuard.configure do |config|
124
124
  # config.branch = "release/2.0"
125
125
  # end
126
126
  #
@@ -318,6 +318,7 @@ module SpecGuard
318
318
  LOCAL_OUTPUT_PATH_KEYS = %w[SPECGUARD_LOCAL_OUTPUT_PATH].freeze
319
319
  ENDPOINT_KEYS = %w[SPECGUARD_ENDPOINT].freeze
320
320
  API_KEY_KEYS = %w[SPECGUARD_API_KEY].freeze
321
+ REPOSITORY_ID_KEYS = %w[SPECGUARD_REPOSITORY_ID].freeze
321
322
  TIMEOUT_KEYS = %w[SPECGUARD_TIMEOUT].freeze
322
323
 
323
324
  # The commit the suite ran against. `nil` when nothing said.
@@ -344,7 +345,9 @@ module SpecGuard
344
345
  attr_accessor :local_output_path
345
346
  # The SpecGuard installation to POST to, scheme and host only:
346
347
  # `https://specguard.example.com`. The `/api/v1/ingest` path is the
347
- # platform's contract, not a setting. `nil` when nothing said.
348
+ # platform's contract, not a setting — with one exception: when
349
+ # {#repository_id} is set the path is `/api/v1/repositories/<id>/ingest`
350
+ # instead. `nil` when nothing said.
348
351
  attr_accessor :endpoint
349
352
  # The repository's SpecGuard API key. Present means "deliver over HTTP".
350
353
  #
@@ -354,6 +357,16 @@ module SpecGuard
354
357
  # nobody can retroactively fix. Send whatever is configured and let a 401
355
358
  # be the answer.
356
359
  attr_accessor :api_key
360
+ # The repository the run belongs to, sent as a path segment
361
+ # (`/api/v1/repositories/<id>/ingest`). Needed by an `sga_` agent key,
362
+ # which covers a set of repositories and so cannot name the run's
363
+ # repository itself; under an `sgk_` key the platform ignores it. `nil`
364
+ # when unset or blank, which keeps the plain `/api/v1/ingest` path.
365
+ #
366
+ # Deliberately **not** format-checked against the key prefix or a numeric
367
+ # shape (same reasoning as {#api_key}): the platform's 400/403/404 is the
368
+ # answer. {Transport} only refuses ids that are unsafe as a path segment.
369
+ attr_accessor :repository_id
357
370
  # Seconds, applied to opening the connection and to reading the response.
358
371
  attr_accessor :timeout
359
372
 
@@ -374,6 +387,7 @@ module SpecGuard
374
387
  @local_output_path = first_present(env, LOCAL_OUTPUT_PATH_KEYS) || DEFAULT_LOCAL_OUTPUT_PATH
375
388
  @endpoint = first_present(env, ENDPOINT_KEYS)
376
389
  @api_key = first_present(env, API_KEY_KEYS)
390
+ @repository_id = first_present(env, REPOSITORY_ID_KEYS)
377
391
  @timeout = seconds(first_present(env, TIMEOUT_KEYS)) || DEFAULT_TIMEOUT_SECONDS
378
392
  end
379
393
 
@@ -433,40 +447,5 @@ module SpecGuard
433
447
  parsed
434
448
  end
435
449
  end
436
-
437
- class << self
438
- # The process-wide formatter configuration.
439
- #
440
- # Built on first use rather than at load time so that a `configure` block
441
- # in a `spec_helper.rb` and a variable exported by a CI job describe the
442
- # same object regardless of which the interpreter reached first.
443
- #
444
- # @return [Configuration]
445
- def configuration
446
- @configuration ||= Configuration.new
447
- end
448
-
449
- # Configure the formatter.
450
- #
451
- # SpecGuard::RSpec.configure do |config|
452
- # config.branch = "release/2.0"
453
- # end
454
- #
455
- # @yieldparam configuration [Configuration]
456
- # @return [Configuration] the configuration, block or no block
457
- def configure
458
- yield configuration if block_given?
459
- configuration
460
- end
461
-
462
- # Drop the memoized configuration so the next read re-seeds from ENV.
463
- # Exists for tests, and for the rare caller that changes the environment
464
- # after this file was loaded.
465
- #
466
- # @return [void]
467
- def reset_configuration!
468
- @configuration = nil
469
- end
470
- end
471
450
  end
472
451
  end
@@ -3,7 +3,7 @@
3
3
  require "open3"
4
4
 
5
5
  module SpecGuard
6
- module RSpec
6
+ module Client
7
7
  # Chooses which spec files the linter reads.
8
8
  #
9
9
  # Two modes: every Ruby test file under the working directory (the
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SpecGuard
4
- module RSpec
4
+ module Client
5
5
  # One `@intent:` annotation, located and (where possible) parsed.
6
6
  #
7
7
  # Exactly one of `intent` and `problem` is non-nil:
@@ -25,7 +25,7 @@ module SpecGuard
25
25
  #
26
26
  # NOTE: this is a subclass of an anonymous `Data.define` rather than a
27
27
  # `Data.define do ... end` block, because a constant assigned inside that
28
- # block binds to the *lexically* enclosing module (SpecGuard::RSpec) rather
28
+ # block binds to the *lexically* enclosing module (SpecGuard::Client) rather
29
29
  # than to the value class — so KIND_EXTRACTION below would silently not be
30
30
  # `Finding::KIND_EXTRACTION`.
31
31
  class Finding < Data.define(:file, :line, :intent, :problem, :kind)
@@ -3,21 +3,21 @@
3
3
  require "json"
4
4
  require "optparse"
5
5
 
6
- require_relative "../rspec"
6
+ require_relative "../client"
7
7
  require_relative "ingest_reporter"
8
8
  require_relative "transport"
9
9
 
10
10
  # `specguard-ingest`'s command line — the other end of `log/test_results.jsonl`.
11
11
  #
12
- # == Why this file is not on `require "specguard/rspec"`'s chain
12
+ # == Why this file is not on `require "specguard/client"`'s chain
13
13
  #
14
14
  # `specguard-lint` runs on machines that never make a network call, and putting
15
15
  # this file on the umbrella's chain would put `net/http`, `uri` and `zlib` on
16
16
  # the linter's load path to serve a command it never invokes. So it is loaded by
17
17
  # its own path, exactly as the formatter is and for the same reason —
18
- # `require "specguard/rspec/ingest_cli"`, which `bin/specguard-ingest` does.
18
+ # `require "specguard/client/ingest_cli"`, which `bin/specguard-ingest` does.
19
19
  module SpecGuard
20
- module RSpec
20
+ module Client
21
21
  # Replays a saved run: reads a `log/test_results.jsonl` back and re-delivers
22
22
  # each line through the {Transport} this gem already ships.
23
23
  #
@@ -740,7 +740,7 @@ module SpecGuard
740
740
  raise UsageError, "no API key is configured (set SPECGUARD_API_KEY)" if blank?(configuration.api_key)
741
741
 
742
742
  transport = Transport.new(endpoint: configuration.endpoint, api_key: configuration.api_key,
743
- timeout: configuration.timeout)
743
+ timeout: configuration.timeout, repository_id: configuration.repository_id)
744
744
  # Asked once, here, so a malformed `SPECGUARD_ENDPOINT` is one exit 2
745
745
  # rather than N identical `:failed` lines. `Transport#deliver` would
746
746
  # otherwise swallow the same `ArgumentError` once per line and report a
@@ -3,7 +3,7 @@
3
3
  require "json"
4
4
 
5
5
  module SpecGuard
6
- module RSpec
6
+ module Client
7
7
  # `specguard-ingest --json`: the machine-readable renderer over the same
8
8
  # per-line facts the human report is built from.
9
9
  #
@@ -3,7 +3,7 @@
3
3
  require "json"
4
4
 
5
5
  module SpecGuard
6
- module RSpec
6
+ module Client
7
7
  # `specguard-lint --json`: the machine-readable renderer over the same
8
8
  # `Array<Linter::Result>` the text report is built from.
9
9
  #
@@ -3,7 +3,7 @@
3
3
  require "json"
4
4
 
5
5
  module SpecGuard
6
- module RSpec
6
+ module Client
7
7
  # The shared verdict shape both renderers and the exit code derive from.
8
8
  #
9
9
  # == Where this sits after SPGD-867 (the cutover)
@@ -3,7 +3,7 @@
3
3
  require "json"
4
4
 
5
5
  module SpecGuard
6
- module RSpec
6
+ module Client
7
7
  # Relaxes PROTOCOL.md §1's permissive annotation syntax into strict JSON.
8
8
  #
9
9
  # §1 promises "the linter normalizes before validating" and lists three
@@ -3,7 +3,7 @@
3
3
  require "json"
4
4
 
5
5
  module SpecGuard
6
- module RSpec
6
+ module Client
7
7
  # Runs the discovery pipeline over source files and returns {Finding}s.
8
8
  #
9
9
  # Pipeline, per `@intent:` token:
@@ -354,6 +354,18 @@ module SpecGuard
354
354
  # example on the NEXT line may claim ({AnnotationLookup::COMMENT_LINE}).
355
355
  COMMENT_INTENT_LINE = /\A\s*#.*@intent:/
356
356
 
357
+ # The one source of the example-call vocabulary, shared by
358
+ # {EXAMPLE_LINE} and {EXAMPLE_ON_LINE}. `it`/`specify` are matched on
359
+ # `\b` alone; the other eight keywords also carry a `(?![:.])`
360
+ # lookahead, which is load-bearing: `example.run` (the around-hook
361
+ # idiom), `example.metadata[:x]` and `focus: true` are not example
362
+ # definitions and must never anchor. (`\b` already excludes `fitness`
363
+ # and `example_group`, a GROUP_LINE keyword.) `skip` and `pending` are
364
+ # deliberately EXCLUDED: at line start they are the runtime call inside
365
+ # an example body, not an example definition.
366
+ EXAMPLE_CALL_SOURCE =
367
+ "(?:(?:it|specify)\\b|(?:example|focus|fexample|fit|fspecify|xexample|xit|xspecify)\\b(?![:.]))"
368
+
357
369
  # The line an annotation run may be claimed from: the first example
358
370
  # keyword. The lookback rule is `example.metadata[:line_number] - 1`, so
359
371
  # only the comment form on the line immediately above an example is
@@ -367,7 +379,14 @@ module SpecGuard
367
379
  # example: stacked lines above it (SPGD-897), the trailing form on
368
380
  # a group line directly above it (SPGD-1510), and a comment-form run
369
381
  # separated from it by one blank/comment line (SPGD-1554).
370
- EXAMPLE_LINE = /\A\s*(?:it|specify)\b/
382
+ #
383
+ # The vocabulary is every example method RSpec core defines
384
+ # (`example focus fexample fit fspecify xexample xit xspecify`, besides
385
+ # `it`/`specify`): lookup is keyed by `example.metadata[:line_number]`, so
386
+ # an annotation stacked/separated/shadowed above `xit` is exactly as dead
387
+ # as one above `it`. Both this anchor and {EXAMPLE_ON_LINE} are built
388
+ # from {EXAMPLE_CALL_SOURCE}, so anchor and exemption cannot drift.
389
+ EXAMPLE_LINE = /\A\s*#{EXAMPLE_CALL_SOURCE}/
371
390
 
372
391
  # A blank (whitespace-only) line — one of the two interleaves the
373
392
  # separated pass reads. `\n` is whitespace, so a `text.lines` element
@@ -574,7 +593,7 @@ module SpecGuard
574
593
  # only ever a missed flag: prose can turn the exemption ON, never a
575
594
  # real example OFF. The one-line example's own payload is never
576
595
  # consulted, so a payload can never exempt anything.
577
- EXAMPLE_ON_LINE = /(?:\bdo\b|\{|;)\s*(?:it|specify)\b/
596
+ EXAMPLE_ON_LINE = /(?:\bdo\b|\{|;)\s*#{EXAMPLE_CALL_SOURCE}/
578
597
 
579
598
  # @param text [String] source of one file
580
599
  # @param file [String] path to record on each Finding
@@ -9,7 +9,7 @@ require_relative "configuration"
9
9
  require_relative "../version"
10
10
 
11
11
  module SpecGuard
12
- module RSpec
12
+ module Client
13
13
  # The one HTTP call this gem makes: `POST <endpoint>/api/v1/ingest`,
14
14
  # carrying a whole run.
15
15
  #
@@ -127,6 +127,21 @@ module SpecGuard
127
127
  # Part of the platform's contract, so it is not configurable — the
128
128
  # `endpoint` setting is the installation's address and nothing more.
129
129
  PATH = "/api/v1/ingest"
130
+
131
+ # The repository-scoped form of the same endpoint (`post "ingest"` nested
132
+ # under `resources :repositories`). It exists because an `sga_` agent key
133
+ # covers a SET of repositories, so the credential cannot name the run's
134
+ # repository and the request has to, as a path segment. `%s` is only ever
135
+ # filled with an id that passed {REPOSITORY_ID_PATTERN}. Under an `sgk_`
136
+ # key the server ignores the segment, so {PATH} stays the default.
137
+ REPOSITORY_PATH = "/api/v1/repositories/%s/ingest"
138
+
139
+ # What a repository id may look like before it becomes a path segment.
140
+ # Deliberately not "digits": the platform's ids are numeric today and the
141
+ # gem must not bake that in. Dots are excluded outright, which is what
142
+ # refuses `..` (percent-escaping would not neutralise a dot segment), and
143
+ # `/`, `?`, `#` can never survive as path structure.
144
+ REPOSITORY_ID_PATTERN = /\A[0-9A-Za-z_-]+\z/
130
145
  CONTENT_TYPE = "application/json"
131
146
  USER_AGENT = "specguard-ruby/#{SpecGuard::VERSION}"
132
147
  CONTENT_ENCODING = "gzip"
@@ -167,7 +182,12 @@ module SpecGuard
167
182
  # same reason turned around: a 202 whose body will not parse is still an
168
183
  # acceptance, and relabelling it would tell the operator something untrue
169
184
  # about a run the platform has already stored. See {#test_run_id}.
170
- Result = Struct.new(:outcome, :code, :error, :reasons, :body, keyword_init: true) do
185
+ #
186
+ # `repository_scoped` is true when the request went to the repository-
187
+ # scoped route. It only changes the 404 sentence: under that route a 404
188
+ # means no repository with that id is available to this key, not a wrong
189
+ # URL. Nil/false (every pre-existing `Result.new`) keeps today's wording.
190
+ Result = Struct.new(:outcome, :code, :error, :reasons, :body, :repository_scoped, keyword_init: true) do
171
191
  # The status codes worth spelling out, because each implies a different
172
192
  # thing for the reader to *do*. A 401 means "rotate or fix the key"; a
173
193
  # 400 means "the payload this gem built was refused", which is a bug
@@ -193,6 +213,10 @@ module SpecGuard
193
213
  # tells the reader how widespread it is.
194
214
  MAX_RENDERED_REASONS = 3
195
215
 
216
+ # The 404 sentence when a repository id was sent. See `repository_scoped`.
217
+ REPOSITORY_NOT_FOUND_ADVICE =
218
+ "no repository with that id is available to this API key — check SPECGUARD_REPOSITORY_ID"
219
+
196
220
  # Ceiling on the spelled-out reasons, before the `and N more` tail,
197
221
  # which is appended afterwards so the count can never be the thing that
198
222
  # gets truncated away.
@@ -224,13 +248,19 @@ module SpecGuard
224
248
  def reason
225
249
  case outcome
226
250
  when :success then nil
227
- when :rejected then [+"HTTP #{code}", ADVICE[code], rendered_reasons].compact.join(" — ")
251
+ when :rejected then [+"HTTP #{code}", advice, rendered_reasons].compact.join(" — ")
228
252
  else "#{error.class}: #{error.message}"
229
253
  end
230
254
  end
231
255
 
232
256
  private
233
257
 
258
+ def advice
259
+ return REPOSITORY_NOT_FOUND_ADVICE if code == 404 && repository_scoped
260
+
261
+ ADVICE[code]
262
+ end
263
+
234
264
  # The refusal, bounded and flattened to fit on the one line. `nil` when
235
265
  # there is nothing readable to add, which is what keeps the bare
236
266
  # `HTTP 400 — the endpoint rejected the payload` intact for every body
@@ -270,9 +300,15 @@ module SpecGuard
270
300
  # @param timeout [Numeric, String, nil] seconds. Anything that is not a
271
301
  # positive finite number falls back to the default rather than raising:
272
302
  # a typo in `SPECGUARD_TIMEOUT` must not be able to fail a suite.
273
- def initialize(endpoint:, api_key:, timeout: Configuration::DEFAULT_TIMEOUT_SECONDS)
303
+ # @param repository_id [String, Integer, nil] when present, POST to the
304
+ # repository-scoped route an `sga_` agent key needs. nil/blank keeps the
305
+ # `sgk_` path byte-identical. An id outside {REPOSITORY_ID_PATTERN} makes
306
+ # {#uri} raise `ArgumentError`.
307
+ def initialize(endpoint:, api_key:, timeout: Configuration::DEFAULT_TIMEOUT_SECONDS, repository_id: nil)
274
308
  @endpoint = endpoint
275
309
  @api_key = api_key
310
+ @repository_id = repository_id.to_s.strip
311
+ @repository_id = nil if @repository_id.empty?
276
312
  @timeout = sanitize_timeout(timeout)
277
313
  end
278
314
 
@@ -301,7 +337,8 @@ module SpecGuard
301
337
  return Result.new(outcome: :success, code: code, body: success_body(response)) if
302
338
  response.is_a?(Net::HTTPSuccess)
303
339
 
304
- Result.new(outcome: :rejected, code: code, reasons: refusal_reasons(response))
340
+ Result.new(outcome: :rejected, code: code, reasons: refusal_reasons(response),
341
+ repository_scoped: !@repository_id.nil?)
305
342
  rescue ScriptError, StandardError => e
306
343
  # Connection refused, DNS failure, TLS failure, open/read timeout, a
307
344
  # malformed endpoint — one family, one shape. `ScriptError` is in the
@@ -427,7 +464,7 @@ module SpecGuard
427
464
  base = @endpoint.to_s.strip
428
465
  raise ArgumentError, "no endpoint is configured (set SPECGUARD_ENDPOINT)" if base.empty?
429
466
 
430
- parsed = URI.parse(base.sub(%r{/+\z}, "") + PATH)
467
+ parsed = URI.parse(base.sub(%r{/+\z}, "") + ingest_path)
431
468
  # `URI::HTTPS < URI::HTTP`, so this admits both and rejects the
432
469
  # scheme-less `specguard.example.com` that `URI.parse` happily returns
433
470
  # as a `URI::Generic` with a nil host — which `Net::HTTP` would then
@@ -438,6 +475,17 @@ module SpecGuard
438
475
  parsed
439
476
  end
440
477
 
478
+ def ingest_path
479
+ return PATH if @repository_id.nil?
480
+
481
+ unless REPOSITORY_ID_PATTERN.match?(@repository_id)
482
+ raise ArgumentError,
483
+ "SPECGUARD_REPOSITORY_ID must contain only letters, digits, '-' and '_', got #{@repository_id.inspect}"
484
+ end
485
+
486
+ format(REPOSITORY_PATH, @repository_id)
487
+ end
488
+
441
489
  def sanitize_timeout(value)
442
490
  parsed = Float(value, exception: false)
443
491
  return Configuration::DEFAULT_TIMEOUT_SECONDS unless parsed&.finite? && parsed.positive?
@@ -7,7 +7,7 @@ require "net/http"
7
7
  require "open3"
8
8
 
9
9
  module SpecGuard
10
- module RSpec
10
+ module Client
11
11
  # The Go validator backend: `validate-intent --source --json`, and since
12
12
  # SPGD-867 the ONLY validator `specguard-lint` has.
13
13
  #
@@ -36,7 +36,7 @@ module SpecGuard
36
36
  # consumers are `Formatter` and `Transport`, and `CLI` does not reference
37
37
  # it at all — so threading the lint backend through it would make the
38
38
  # linter depend on the telemetry object solely to read one env var. It also
39
- # memoizes process-wide (`SpecGuard::RSpec.configuration`), with
39
+ # memoizes process-wide (`SpecGuard.configuration`), with
40
40
  # `reset_configuration!` existing only for tests, which is a poor fit for
41
41
  # something a spec wants to vary per example. {CLI} takes an `env:` instead
42
42
  # and asks this module, so the seam is one hash away in a test and one
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "version"
4
+
5
+ module SpecGuard
6
+ module Client
7
+ # The Ruby client for SpecGuard.
8
+ #
9
+ # The linter is complete end to end: it finds `@intent:` annotations in
10
+ # spec files, captures each payload string-aware, normalizes PROTOCOL.md
11
+ # §1's permissive syntax into strict JSON, validates the result against the
12
+ # vendored OpenTestIntent schema, and reports violations in the reference
13
+ # tool's own grammar under the 0/1/2 exit contract (see {CLI}).
14
+ #
15
+ # Everything under `SpecGuard::Client` is framework-free: the RSpec
16
+ # formatter and the Minitest reporter are the framework halves of the
17
+ # client and both build on it. Neither is required from here — `rspec`
18
+ # is a development dependency of this gem, not a runtime one, and
19
+ # `bin/specguard-lint` loads this file on machines that may have no RSpec
20
+ # at all. Requiring `rspec/core` from this chain would turn a missing test
21
+ # framework into a broken linter. Load the formatter by its own path when
22
+ # you want it — `require "specguard/rspec/formatter"` — and see that file
23
+ # for the opt-in wiring.
24
+ class Error < StandardError; end
25
+
26
+ # A source line could not be scanned (unterminated string or object
27
+ # literal). Internal to the scanner: it is caught and turned into a
28
+ # {Finding}'s `problem` rather than reaching a caller.
29
+ class ScanError < Error; end
30
+
31
+ # The tool was invoked in a way that cannot work — `--changed` outside a git
32
+ # repository, say. Distinct from "the annotations are bad": the exit-code
33
+ # contract makes this a 2, and it is typed here so the CLI can map it
34
+ # without re-deriving the distinction from an error message.
35
+ class UsageError < Error; end
36
+
37
+ # The Go validator backend could not produce a verdict — the binary named
38
+ # by `SPECGUARD_VALIDATE_INTENT`, or the default binary the backend
39
+ # resolves when that is blank, is missing, will not execute, exited with a
40
+ # code that is not a verdict, or emitted something this cannot read as a
41
+ # report.
42
+ #
43
+ # Typed separately from {UsageError} because the two are different
44
+ # accusations ("you invoked me wrongly" vs "the tool I was told to use is
45
+ # broken"), and rescued *beside* it in {CLI#run} because the answer to both
46
+ # is the same and non-negotiable: exit 2, never 1. See {ValidatorBackend}.
47
+ class ValidatorError < Error; end
48
+
49
+ # The vendored canonical OpenTestIntent schema, copied byte-for-byte from
50
+ # open-test-intent so this gem has **no cross-repo runtime dependency**.
51
+ # It ships packaged (it lives under `lib/`, which the gemspec includes);
52
+ # the spec fixtures deliberately do not, so nothing here may be
53
+ # load-bearing at runtime.
54
+ #
55
+ # Since the SPGD-867 cutover nothing VALIDATES against this copy — every
56
+ # verdict comes from the `validate-intent` binary's own compiled-in
57
+ # schema. It survives because {ValidatorBackend}'s schema-contract check
58
+ # digests it at runtime and refuses a binary that would enforce different
59
+ # bytes: the two halves of the seam must keep meeting somewhere, and this
60
+ # file is where the gem's half lives.
61
+ SCHEMA_PATH = File.expand_path("client/schemas/open-test-intent.v1.json", __dir__).freeze
62
+ end
63
+ end
64
+
65
+ module SpecGuard
66
+ class << self
67
+ # The process-wide client configuration — one object shared by the RSpec
68
+ # formatter, the Minitest reporter and the CLIs, whatever framework a
69
+ # consumer runs.
70
+ #
71
+ # Built on first use rather than at load time so that a `configure` block
72
+ # in a `spec_helper.rb` / `test_helper.rb` and a variable exported by a CI
73
+ # job describe the same object regardless of which the interpreter reached
74
+ # first.
75
+ #
76
+ # {SpecGuard::RSpec.configure} (the documented RSpec entry point) delegates
77
+ # here and returns this same object — there is exactly one configuration,
78
+ # not one per entry point.
79
+ #
80
+ # @return [Client::Configuration]
81
+ def configuration
82
+ # Loaded here, not at the top of this file: the linter's chain
83
+ # (`require "specguard/client"`) must stay free of the formatter's
84
+ # configuration/transport half — formatter_loading_spec pins it.
85
+ require_relative "client/configuration"
86
+ @configuration ||= Client::Configuration.new
87
+ end
88
+
89
+ # Configure the client.
90
+ #
91
+ # SpecGuard.configure do |config|
92
+ # config.branch = "release/2.0"
93
+ # end
94
+ #
95
+ # @yieldparam configuration [Client::Configuration]
96
+ # @return [Client::Configuration] the configuration, block or no block
97
+ def configure
98
+ yield configuration if block_given?
99
+ configuration
100
+ end
101
+
102
+ # Drop the memoized configuration so the next read re-seeds from ENV.
103
+ # Exists for tests, and for the rare caller that changes the environment
104
+ # after this file was loaded.
105
+ #
106
+ # @return [void]
107
+ def reset_configuration!
108
+ @configuration = nil
109
+ end
110
+ end
111
+ end
112
+
113
+ require_relative "client/finding"
114
+ require_relative "client/annotation_scanner"
115
+ require_relative "client/payload_normalizer"
116
+ require_relative "client/scanner"
117
+ require_relative "client/file_selector"
118
+ require_relative "client/linter"
119
+ require_relative "client/json_reporter"
120
+ require_relative "client/validator_backend"
121
+ require_relative "client/cli"
@@ -4,11 +4,11 @@ require "json"
4
4
  require "fileutils"
5
5
 
6
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"
7
+ # (env resolution, wire delivery, the never-raise contract), under
8
+ # `SpecGuard::Client`. Required here rather than assumed because nothing else
9
+ # loads them on a minitest-only consumer's machine.
10
+ require_relative "../client"
11
+ require_relative "../client/configuration"
12
12
 
13
13
  # The annotation lookup is the third framework-free half: what counts as a
14
14
  # test's annotation is a property of the source line, not of the framework
@@ -17,20 +17,19 @@ require_relative "../rspec/configuration"
17
17
  # already warns that a second extractor guarantees scanner and reporter
18
18
  # eventually disagree about what an annotation is). Requiring it pulls the
19
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
20
+ # `specguard/client` does not require `rspec/core`, so a minitest-only machine
21
21
  # stays loadable.
22
- require_relative "../rspec/annotation_lookup"
22
+ require_relative "../client/annotation_lookup"
23
23
 
24
24
  # The Minitest half of `specguard-ruby`. Everything the RSpec formatter knows
25
25
  # about the platform — the envelope's field names, the transport's
26
26
  # never-raise contract, the switch that a missing key is — is framework-free
27
- # knowledge that already lives in `SpecGuard::RSpec::Configuration` and
28
- # `SpecGuard::RSpec::Transport` (both named for the framework that happened to
29
- # ship first, neither containing any RSpec). This adapter contributes only the
27
+ # knowledge that already lives in `SpecGuard::Client::Configuration` and
28
+ # `SpecGuard::Client::Transport`. This adapter contributes only the
30
29
  # part that is genuinely Minitest's: turning `Minitest::Result` objects into
31
30
  # the same row shape the RSpec formatter emits, at the moment Minitest hands
32
31
  # them to a reporter — and attaching each test's annotation the same way the
33
- # formatter does, through the shared {SpecGuard::RSpec::AnnotationLookup} and
32
+ # formatter does, through the shared {SpecGuard::Client::AnnotationLookup} and
34
33
  # its one-line lookback (SPGD-12 §2), so a Minitest row carries the same
35
34
  # `status` / `intent` pair a RSpec row does.
36
35
  #
@@ -78,7 +77,7 @@ module SpecGuard
78
77
  end
79
78
  end
80
79
 
81
- def initialize(configuration: SpecGuard::RSpec.configuration,
80
+ def initialize(configuration: SpecGuard.configuration,
82
81
  transport: nil, output: $stderr,
83
82
  annotations: nil,
84
83
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
@@ -108,7 +107,7 @@ module SpecGuard
108
107
  # binds one root, hands it to the default lookup, and pins rspec-core's
109
108
  # lazily memoized relativization regex to the same moment.)
110
109
  root = self.class.repo_root
111
- @annotations = annotations || SpecGuard::RSpec::AnnotationLookup.new(root: root)
110
+ @annotations = annotations || SpecGuard::Client::AnnotationLookup.new(root: root)
112
111
  @clock = clock
113
112
  @rows = []
114
113
  @started_at = nil
@@ -244,11 +243,12 @@ module SpecGuard
244
243
  end
245
244
 
246
245
  def transport_for
247
- require_relative "../rspec/transport"
248
- SpecGuard::RSpec::Transport.new(
246
+ require_relative "../client/transport"
247
+ SpecGuard::Client::Transport.new(
249
248
  endpoint: @configuration.endpoint,
250
249
  api_key: @configuration.api_key,
251
- timeout: @configuration.timeout
250
+ timeout: @configuration.timeout,
251
+ repository_id: @configuration.repository_id
252
252
  )
253
253
  end
254
254
 
@@ -406,12 +406,21 @@ module SpecGuard
406
406
  # Report, not promise — see `#fall_back` for why that is the whole of
407
407
  # this slice, and why the order above it did not move.
408
408
  def sink_clause(path, error)
409
- return "Falling back to the replay queue. The test run is unaffected." if error.nil?
409
+ return replay_clause(path) if error.nil?
410
410
 
411
411
  "The replay queue #{path} could not be written either " \
412
412
  "(#{error.class}: #{error.message}), so this run's telemetry was lost."
413
413
  end
414
414
 
415
+ # The success arm (SPGD-1139): names the configured path (the one
416
+ # `#append` just wrote) and the replay command, word-for-word the
417
+ # formatter's clause. The lost-run arm names no command — nothing to
418
+ # replay.
419
+ def replay_clause(path)
420
+ "Falling back to #{path}; the test run is unaffected. " \
421
+ "Once the delivery is fixed, replay this run with: bundle exec specguard-ingest #{path}"
422
+ end
423
+
415
424
  # Once per process, like the formatter's `warn_once`: fifty failing
416
425
  # deliveries is one problem, not fifty lines of it.
417
426
  def warn_once(message)
@@ -40,13 +40,19 @@ require "rspec/core/formatters/base_formatter"
40
40
  require "json"
41
41
  require "fileutils"
42
42
 
43
- require_relative "configuration"
44
- require_relative "transport"
43
+ # `specguard/rspec` is what defines `SpecGuard::RSpec` — the namespace whose
44
+ # existence is the shadowing hazard documented below, and the home of the
45
+ # documented `SpecGuard::RSpec.configure` entry point. It is required here so
46
+ # that both stay true however this file is reached, now that the client chain
47
+ # no longer pulls it in.
48
+ require_relative "../rspec"
49
+ require_relative "../client/configuration"
50
+ require_relative "../client/transport"
45
51
  # Brings the linter's discovery chain with it (Scanner, Finding, Schema). That
46
52
  # direction is safe — `specguard/rspec` does not require `rspec/core`, so the
47
53
  # linter stays loadable without RSpec; it is only the reverse that would break
48
54
  # packaging.
49
- require_relative "annotation_lookup"
55
+ require_relative "../client/annotation_lookup"
50
56
 
51
57
  module SpecGuard
52
58
  # == A NOTE ON CONSTANT RESOLUTION — read this before editing
@@ -338,7 +344,7 @@ module SpecGuard
338
344
  root = self.class.repo_root
339
345
  ::RSpec::Core::Metadata.relative_path_regex
340
346
  @error_stream = error_stream
341
- @annotations = annotations || SpecGuard::RSpec::AnnotationLookup.new(root: root)
347
+ @annotations = annotations || SpecGuard::Client::AnnotationLookup.new(root: root)
342
348
  @specs = []
343
349
  @warned = false
344
350
  # Stamped here rather than from a `start` hook on purpose. `:start` is not
@@ -1031,10 +1037,11 @@ module SpecGuard
1031
1037
  end
1032
1038
 
1033
1039
  def transport_for(configuration)
1034
- SpecGuard::RSpec::Transport.new(
1040
+ SpecGuard::Client::Transport.new(
1035
1041
  endpoint: configuration.endpoint,
1036
1042
  api_key: configuration.api_key,
1037
- timeout: configuration.timeout
1043
+ timeout: configuration.timeout,
1044
+ repository_id: configuration.repository_id
1038
1045
  )
1039
1046
  end
1040
1047
 
@@ -1196,12 +1203,22 @@ module SpecGuard
1196
1203
  # which of the two things actually happened — see {#fall_back} for why that
1197
1204
  # is the whole of this slice, and why the order above it did not move.
1198
1205
  def sink_clause(path, error)
1199
- return "Falling back to #{path}; the test run is unaffected." if error.nil?
1206
+ return replay_clause(path) if error.nil?
1200
1207
 
1201
1208
  "The replay queue #{path} could not be written either " \
1202
1209
  "(#{error.class}: #{error.message}), so this run's telemetry was lost."
1203
1210
  end
1204
1211
 
1212
+ # The success arm (SPGD-1139): where the run sits AND the command that gets
1213
+ # it back, fix-the-delivery first and replay after — the order the ingest
1214
+ # CLI's own doctrine describes. The lost-run arm above deliberately names no
1215
+ # command: with no queue written there is nothing to replay. Kept
1216
+ # word-for-word identical to the Minitest reporter's clause.
1217
+ def replay_clause(path)
1218
+ "Falling back to #{path}; the test run is unaffected. " \
1219
+ "Once the delivery is fixed, replay this run with: bundle exec specguard-ingest #{path}"
1220
+ end
1221
+
1205
1222
  def emit_warning(line)
1206
1223
  return if @warned
1207
1224
 
@@ -1,72 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "rspec/version"
4
+ require_relative "client"
4
5
 
5
6
  module SpecGuard
7
+ # The RSpec-facing entry point of the gem.
8
+ #
9
+ # The framework-free client — scanner, linter, CLIs, configuration,
10
+ # transport — lives under {SpecGuard::Client}; the one component that is
11
+ # genuinely RSpec's is {SpecGuard::RSpecFormatter}
12
+ # (`require "specguard/rspec/formatter"`). What remains here is the
13
+ # documented `SpecGuard::RSpec.configure` entry point, kept working for
14
+ # anybody who configures the client from a `spec_helper.rb`.
15
+ #
16
+ # It is a thin delegate, not a second configuration: all three methods
17
+ # forward to {SpecGuard.configure} / {SpecGuard.configuration} /
18
+ # {SpecGuard.reset_configuration!}, so `SpecGuard::RSpec.configure` and
19
+ # `SpecGuard.configure` return the very same {Client::Configuration} object.
20
+ # A Minitest consumer has no reason to say `RSpec` and should configure
21
+ # through `SpecGuard.configure`.
6
22
  module RSpec
7
- # The Ruby client for SpecGuard.
8
- #
9
- # The linter is complete end to end: it finds `@intent:` annotations in
10
- # spec files, captures each payload string-aware, normalizes PROTOCOL.md
11
- # §1's permissive syntax into strict JSON, validates the result against the
12
- # vendored OpenTestIntent schema, and reports violations in the reference
13
- # tool's own grammar under the 0/1/2 exit contract (see {CLI}).
14
- #
15
- # The RSpec formatter (`SpecGuard::RSpecFormatter`) is the other half of
16
- # the client, and it is deliberately **not** required from here: `rspec` is
17
- # a development dependency of this gem, not a runtime one, and
18
- # `bin/specguard-lint` loads this file on machines that may have no RSpec
19
- # at all. Requiring `rspec/core` from this chain would turn a missing test
20
- # framework into a broken linter. Load it by its own path when you want it
21
- # — `require "specguard/rspec/formatter"` — and see that file for the
22
- # opt-in wiring.
23
- class Error < StandardError; end
23
+ class << self
24
+ # @yieldparam configuration [Client::Configuration]
25
+ # @return [Client::Configuration] the same object {SpecGuard.configure} returns
26
+ def configure(&)
27
+ SpecGuard.configure(&)
28
+ end
24
29
 
25
- # A source line could not be scanned (unterminated string or object
26
- # literal). Internal to the scanner: it is caught and turned into a
27
- # {Finding}'s `problem` rather than reaching a caller.
28
- class ScanError < Error; end
30
+ # @return [Client::Configuration]
31
+ def configuration
32
+ SpecGuard.configuration
33
+ end
29
34
 
30
- # The tool was invoked in a way that cannot work — `--changed` outside a git
31
- # repository, say. Distinct from "the annotations are bad": the exit-code
32
- # contract makes this a 2, and it is typed here so the CLI can map it
33
- # without re-deriving the distinction from an error message.
34
- class UsageError < Error; end
35
-
36
- # The Go validator backend could not produce a verdict — the binary named
37
- # by `SPECGUARD_VALIDATE_INTENT`, or the default binary the backend
38
- # resolves when that is blank, is missing, will not execute, exited with a
39
- # code that is not a verdict, or emitted something this cannot read as a
40
- # report.
41
- #
42
- # Typed separately from {UsageError} because the two are different
43
- # accusations ("you invoked me wrongly" vs "the tool I was told to use is
44
- # broken"), and rescued *beside* it in {CLI#run} because the answer to both
45
- # is the same and non-negotiable: exit 2, never 1. See {ValidatorBackend}.
46
- class ValidatorError < Error; end
47
-
48
- # The vendored canonical OpenTestIntent schema, copied byte-for-byte from
49
- # open-test-intent so this gem has **no cross-repo runtime dependency**.
50
- # It ships packaged (it lives under `lib/`, which the gemspec includes);
51
- # the spec fixtures deliberately do not, so nothing here may be
52
- # load-bearing at runtime.
53
- #
54
- # Since the SPGD-867 cutover nothing VALIDATES against this copy — every
55
- # verdict comes from the `validate-intent` binary's own compiled-in
56
- # schema. It survives because {ValidatorBackend}'s schema-contract check
57
- # digests it at runtime and refuses a binary that would enforce different
58
- # bytes: the two halves of the seam must keep meeting somewhere, and this
59
- # file is where the gem's half lives.
60
- SCHEMA_PATH = File.expand_path("rspec/schemas/open-test-intent.v1.json", __dir__).freeze
35
+ # @return [void]
36
+ def reset_configuration!
37
+ SpecGuard.reset_configuration!
38
+ end
39
+ end
61
40
  end
62
41
  end
63
-
64
- require_relative "rspec/finding"
65
- require_relative "rspec/annotation_scanner"
66
- require_relative "rspec/payload_normalizer"
67
- require_relative "rspec/scanner"
68
- require_relative "rspec/file_selector"
69
- require_relative "rspec/linter"
70
- require_relative "rspec/json_reporter"
71
- require_relative "rspec/validator_backend"
72
- require_relative "rspec/cli"
@@ -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.29"
10
+ VERSION = "0.3.31"
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.29
4
+ version: 0.3.31
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-10-01 00:00:00.000000000 Z
11
+ date: 2026-10-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json
@@ -47,24 +47,25 @@ files:
47
47
  - bin/specguard-ingest
48
48
  - bin/specguard-lint
49
49
  - lib/minitest/specguard_plugin.rb
50
+ - lib/specguard/client.rb
51
+ - lib/specguard/client/annotation_lookup.rb
52
+ - lib/specguard/client/annotation_scanner.rb
53
+ - lib/specguard/client/cli.rb
54
+ - lib/specguard/client/configuration.rb
55
+ - lib/specguard/client/file_selector.rb
56
+ - lib/specguard/client/finding.rb
57
+ - lib/specguard/client/ingest_cli.rb
58
+ - lib/specguard/client/ingest_reporter.rb
59
+ - lib/specguard/client/json_reporter.rb
60
+ - lib/specguard/client/linter.rb
61
+ - lib/specguard/client/payload_normalizer.rb
62
+ - lib/specguard/client/scanner.rb
63
+ - lib/specguard/client/schemas/open-test-intent.v1.json
64
+ - lib/specguard/client/transport.rb
65
+ - lib/specguard/client/validator_backend.rb
50
66
  - lib/specguard/minitest/reporter.rb
51
67
  - lib/specguard/rspec.rb
52
- - lib/specguard/rspec/annotation_lookup.rb
53
- - lib/specguard/rspec/annotation_scanner.rb
54
- - lib/specguard/rspec/cli.rb
55
- - lib/specguard/rspec/configuration.rb
56
- - lib/specguard/rspec/file_selector.rb
57
- - lib/specguard/rspec/finding.rb
58
68
  - lib/specguard/rspec/formatter.rb
59
- - lib/specguard/rspec/ingest_cli.rb
60
- - lib/specguard/rspec/ingest_reporter.rb
61
- - lib/specguard/rspec/json_reporter.rb
62
- - lib/specguard/rspec/linter.rb
63
- - lib/specguard/rspec/payload_normalizer.rb
64
- - lib/specguard/rspec/scanner.rb
65
- - lib/specguard/rspec/schemas/open-test-intent.v1.json
66
- - lib/specguard/rspec/transport.rb
67
- - lib/specguard/rspec/validator_backend.rb
68
69
  - lib/specguard/rspec/version.rb
69
70
  - lib/specguard/version.rb
70
71
  - script/bump-version.sh