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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +49 -0
- data/.github/workflows/release.yml +100 -0
- data/LICENSE +21 -0
- data/README.md +1036 -0
- data/Rakefile +28 -0
- data/assets/built-with-yatfa.png +0 -0
- data/bin/specguard-ingest +40 -0
- data/bin/specguard-lint +34 -0
- data/lib/minitest/specguard_plugin.rb +25 -0
- data/lib/specguard/minitest/reporter.rb +244 -0
- data/lib/specguard/rspec/annotation_lookup.rb +363 -0
- data/lib/specguard/rspec/annotation_scanner.rb +190 -0
- data/lib/specguard/rspec/cli.rb +375 -0
- data/lib/specguard/rspec/configuration.rb +472 -0
- data/lib/specguard/rspec/file_selector.rb +276 -0
- data/lib/specguard/rspec/finding.rb +74 -0
- data/lib/specguard/rspec/formatter.rb +1110 -0
- data/lib/specguard/rspec/ingest_cli.rb +942 -0
- data/lib/specguard/rspec/ingest_reporter.rb +280 -0
- data/lib/specguard/rspec/json_reporter.rb +169 -0
- data/lib/specguard/rspec/linter.rb +156 -0
- data/lib/specguard/rspec/payload_normalizer.rb +143 -0
- data/lib/specguard/rspec/scanner.rb +235 -0
- data/lib/specguard/rspec/schemas/open-test-intent.v1.json +15 -0
- data/lib/specguard/rspec/transport.rb +449 -0
- data/lib/specguard/rspec/validator_backend.rb +1450 -0
- data/lib/specguard/rspec/version.rb +14 -0
- data/lib/specguard/rspec.rb +71 -0
- data/lib/specguard/version.rb +11 -0
- data/script/bump-version.sh +128 -0
- metadata +96 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Answers one question, for one example at a time: *is this test annotated, and
|
|
4
|
+
# with what?*
|
|
5
|
+
#
|
|
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
|
|
8
|
+
# `@intent:` in a file, captures its payload string-aware and parses it, and
|
|
9
|
+
# the `validate-intent` binary decides whether the result is valid. This class
|
|
10
|
+
# consumes both. Writing a second extractor would guarantee that the tool
|
|
11
|
+
# telling an author their annotation is wrong and the tool reporting it to the
|
|
12
|
+
# platform eventually disagree about what an annotation *is*.
|
|
13
|
+
#
|
|
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
|
|
16
|
+
# the formatter's side keeps `bin/specguard-lint` loadable on a machine with no
|
|
17
|
+
# RSpec installed. spec/specguard/rspec/formatter_loading_spec.rb pins that in
|
|
18
|
+
# both directions.
|
|
19
|
+
require_relative "../rspec"
|
|
20
|
+
|
|
21
|
+
module SpecGuard
|
|
22
|
+
module RSpec
|
|
23
|
+
# == The lookback rule (SPGD-12 §2)
|
|
24
|
+
#
|
|
25
|
+
# `example.metadata[:line_number]` is the line the `it` is on. An annotation
|
|
26
|
+
# counts as this example's when it sits on that line (the trailing form) or
|
|
27
|
+
# on the line immediately above it (the preceding-comment form):
|
|
28
|
+
#
|
|
29
|
+
# # @intent: { entity: "Order", ... } <- line above
|
|
30
|
+
# it "restores stock on refund" do <- metadata[:line_number]
|
|
31
|
+
#
|
|
32
|
+
# it "surfaces the decline reason" do # @intent: { ... } <- same line
|
|
33
|
+
#
|
|
34
|
+
# One line of lookback, no more. Multi-line annotations are unsupported in
|
|
35
|
+
# v1 (PROTOCOL.md §1 requires an annotation to fit on one line), so a wider
|
|
36
|
+
# window could only ever attach an annotation to the wrong example.
|
|
37
|
+
#
|
|
38
|
+
# == Only the comment form is inherited by the line below
|
|
39
|
+
#
|
|
40
|
+
# The two forms are not symmetric, and the difference is not cosmetic. A
|
|
41
|
+
# comment-only line hosts no example, so an annotation written there has
|
|
42
|
+
# exactly one possible claimant — the line under it. An annotation written
|
|
43
|
+
# in the trailing form is on a line that *is* an example's, so it has two,
|
|
44
|
+
# and one-liner specs make the second one ordinary rather than exotic:
|
|
45
|
+
#
|
|
46
|
+
# it { is_expected.to eq(1) } # @intent: { entity: "Order", ... }
|
|
47
|
+
# it { is_expected.to be_positive }
|
|
48
|
+
#
|
|
49
|
+
# Lookback that did not distinguish the forms would report **both** of those
|
|
50
|
+
# as annotated, carrying the same intent — so the platform would store the
|
|
51
|
+
# second example's declared purpose as something nobody declared, and count
|
|
52
|
+
# it in `annotated_specs_count`. That is worse than reporting it
|
|
53
|
+
# unannotated: a missing annotation is a visible gap, an invented one is
|
|
54
|
+
# confident false telemetry, and it inflates the very ratio `status` exists
|
|
55
|
+
# to keep honest.
|
|
56
|
+
#
|
|
57
|
+
# So a Finding is claimable by the line below it only when its own line is
|
|
58
|
+
# comment-only ({COMMENT_LINE}). Deciding that needs the source line, which
|
|
59
|
+
# is why {#build_index} reads the file itself and calls {Scanner.scan_text}
|
|
60
|
+
# rather than {Scanner.scan_file} — the read is the same single read either
|
|
61
|
+
# way, and the alternative was a second one per file purely to re-derive
|
|
62
|
+
# what the first one already had in hand.
|
|
63
|
+
#
|
|
64
|
+
# == Why a malformed annotation is reported as *unannotated*
|
|
65
|
+
#
|
|
66
|
+
# {AnnotationScanner} states, correctly, that a typo'd annotation "must fail
|
|
67
|
+
# loudly rather than silently counting as unannotated". That is the
|
|
68
|
+
# **linter's** stance, and the linter already acts on it: it prints the
|
|
69
|
+
# file:line and exits 1. It cannot bind the formatter, which is under the
|
|
70
|
+
# never-block-CI contract and has no way to be loud that is not also a way
|
|
71
|
+
# to be in someone's build output.
|
|
72
|
+
#
|
|
73
|
+
# The alternative is worse than it looks. `Ingest::Payload` is
|
|
74
|
+
# all-or-nothing — it collects errors globally and `valid?` requires the
|
|
75
|
+
# list to be empty — so shipping one schema-invalid intent does not lose
|
|
76
|
+
# that annotation, it loses **the whole run**: a 400, and no telemetry for
|
|
77
|
+
# any of the other twenty thousand examples. Downgrading one annotation to
|
|
78
|
+
# `unannotated` costs one row's metadata. The example's name, duration and
|
|
79
|
+
# outcome still ship either way.
|
|
80
|
+
#
|
|
81
|
+
# So every one of these produces the same answer — `nil`, meaning
|
|
82
|
+
# unannotated:
|
|
83
|
+
#
|
|
84
|
+
# * no `@intent:` on either candidate line;
|
|
85
|
+
# * an `@intent:` on the line above that is *not* a comment-only line —
|
|
86
|
+
# the trailing form belongs to its own example and lends itself to
|
|
87
|
+
# nobody;
|
|
88
|
+
# * an `@intent:` whose payload could not be captured or parsed
|
|
89
|
+
# ({Finding#problem?} — `KIND_EXTRACTION` / `KIND_PARSE`);
|
|
90
|
+
# * a file that could not be read at all (`KIND_READ`);
|
|
91
|
+
# * a syntactically fine annotation the schema rejects;
|
|
92
|
+
# * the schema itself failing to load.
|
|
93
|
+
#
|
|
94
|
+
# == Cost
|
|
95
|
+
#
|
|
96
|
+
# {AnnotationScanner.each_intent} walks every line of the file it is handed.
|
|
97
|
+
# Scanning per *example* would make a 20,000-example suite read and walk its
|
|
98
|
+
# spec files 20,000 times, on the critical path of somebody's test run. So
|
|
99
|
+
# each file is read and scanned at most once and reduced to an {Index}:
|
|
100
|
+
# O(files), not O(examples). Schema validation is memoized on the same
|
|
101
|
+
# principle — per distinct annotation, not per example carrying it.
|
|
102
|
+
class AnnotationLookup
|
|
103
|
+
# Findings at line 0 are {Finding::KIND_READ} — "this file was never
|
|
104
|
+
# read", not "there is an annotation on line 0". Keeping the sentinel out
|
|
105
|
+
# of the index is what makes {Index#finding_for}'s unconditional
|
|
106
|
+
# `line - 1` lookback safe for an example on line 1.
|
|
107
|
+
FIRST_REAL_LINE = 1
|
|
108
|
+
|
|
109
|
+
# A line whose only content is a comment: the preceding-comment form, and
|
|
110
|
+
# the only form an example on the next line may claim. Leading whitespace
|
|
111
|
+
# is allowed because annotations are indented with the examples they
|
|
112
|
+
# describe.
|
|
113
|
+
COMMENT_LINE = /\A\s*#/
|
|
114
|
+
|
|
115
|
+
# One file's annotations, split by who is allowed to claim them.
|
|
116
|
+
#
|
|
117
|
+
# own every annotation, by the line it was written on
|
|
118
|
+
# inheritable only those in the comment form, by that same line
|
|
119
|
+
#
|
|
120
|
+
# The values are the INTENT TO SHIP or nil — the verdict already applied —
|
|
121
|
+
# rather than a Finding, because the two paths that build this index reach
|
|
122
|
+
# a verdict by different routes (the local Scanner for the LINES, or the
|
|
123
|
+
# port's report for the verdicts) and only agree on the answer. Storing the answer is what
|
|
124
|
+
# keeps `#intent_for` from having to know which one ran.
|
|
125
|
+
#
|
|
126
|
+
# A line's own annotation always wins: the trailing form is written *on*
|
|
127
|
+
# the example, so when a file somehow carries both it is the more specific
|
|
128
|
+
# of the two, and a malformed trailing annotation is not quietly
|
|
129
|
+
# backfilled from the comment above it — that would attach an intent its
|
|
130
|
+
# author had already stopped meaning. Hence `key?` rather than a truthy
|
|
131
|
+
# test: a line whose own annotation was rejected answers nil, and must not
|
|
132
|
+
# fall through to the comment above it.
|
|
133
|
+
#
|
|
134
|
+
# `line - 1` is 0 for an example on line 1, and {AnnotationLookup#build_index}
|
|
135
|
+
# is what guarantees that reads back as "no annotation": line 0 is never a
|
|
136
|
+
# key, because the only finding produced there is the KIND_READ sentinel.
|
|
137
|
+
Index = Struct.new(:own, :inheritable) do
|
|
138
|
+
# @return [Hash, nil]
|
|
139
|
+
def intent_for(line)
|
|
140
|
+
return own[line] if own.key?(line)
|
|
141
|
+
|
|
142
|
+
inheritable[line - 1]
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The answer for a file nothing could be read out of. Its own constant so
|
|
147
|
+
# the pessimistic pre-scan cache in {#index_for} and the unreadable-file
|
|
148
|
+
# path cannot drift apart.
|
|
149
|
+
EMPTY_INDEX = Index.new({}.freeze, {}.freeze).freeze
|
|
150
|
+
|
|
151
|
+
# @param env [Hash, ENV] where `SPECGUARD_VALIDATE_INTENT` is read from.
|
|
152
|
+
# Injected for testing, and read LAZILY — see {#backend}.
|
|
153
|
+
def initialize(env: ENV)
|
|
154
|
+
@env = env
|
|
155
|
+
@indexes = {}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The intent to attach to one example, or nil when it is unannotated.
|
|
159
|
+
#
|
|
160
|
+
# Nothing here is rescued: a caller that cannot survive an exception must
|
|
161
|
+
# say so itself, and the formatter does — it wraps this in the same
|
|
162
|
+
# `never_fail_the_run` envelope as everything else, so a blow-up costs the
|
|
163
|
+
# example its annotation and not the suite its exit code. The memoization
|
|
164
|
+
# below is deliberately written so that a failure is cached too, which is
|
|
165
|
+
# what keeps "warns once" from becoming "warns once but rescans the file
|
|
166
|
+
# for every one of the remaining examples".
|
|
167
|
+
#
|
|
168
|
+
# @param file [String, nil] the example's file, as the payload records it
|
|
169
|
+
# @param line [Integer, nil] `example.metadata[:line_number]`
|
|
170
|
+
# @return [Hash, nil] the parsed, schema-valid annotation
|
|
171
|
+
def intent_for(file:, line:)
|
|
172
|
+
return nil unless file.is_a?(String) && !file.empty?
|
|
173
|
+
return nil unless line.is_a?(Integer) && line >= FIRST_REAL_LINE
|
|
174
|
+
|
|
175
|
+
index_for(file).intent_for(line)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
# The validator backend, or nil — memoized, including when resolving
|
|
181
|
+
# RAISES.
|
|
182
|
+
#
|
|
183
|
+
# WHY THIS CLASS RESOLVES ONE AT ALL. `CLI` routes the linter through the
|
|
184
|
+
# backend, and since the SPGD-867 cutover the backend is the ONLY
|
|
185
|
+
# validator: the gem no longer carries a Ruby validation arm. Whatever
|
|
186
|
+
# the backend accepts is what the platform must receive, so this asks
|
|
187
|
+
# the same binary the same question rather than a second parser that
|
|
188
|
+
# could disagree with it.
|
|
189
|
+
#
|
|
190
|
+
# Lazily, not in the constructor: {Formatter} builds this at suite start,
|
|
191
|
+
# and `resolve` verifies the binary (two probes, and a raise when it is
|
|
192
|
+
# unusable). Doing that during construction would move a failure out of
|
|
193
|
+
# the formatter's `never_fail_the_run` envelope and into the point where
|
|
194
|
+
# RSpec is still wiring itself up.
|
|
195
|
+
#
|
|
196
|
+
# A {ValidatorError} HERE IS NOT FATAL AND IS NOT nil-THE-ANNOTATION. An
|
|
197
|
+
# unusable binary means this class cannot ask the backend what an
|
|
198
|
+
# annotation says; it does NOT mean the annotation is bad. Since the
|
|
199
|
+
# cutover there is no Ruby arm to fall back to, so probe failure is
|
|
200
|
+
# memoized as nil and `#verdicts_for` answers `unverified_verdicts` for
|
|
201
|
+
# every file: local line discovery keeps working and every annotation is
|
|
202
|
+
# reported as `unannotated` — the honest answer under the formatter's
|
|
203
|
+
# never-fail-the-run contract, while the linter half makes the failure
|
|
204
|
+
# loud (it exits 2 when no binary can be resolved, naming the
|
|
205
|
+
# remediations).
|
|
206
|
+
#
|
|
207
|
+
# The alternative was letting the raise reach {Formatter}'s
|
|
208
|
+
# `never_fail_the_run` envelope. That envelope keeps the RUN alive, but
|
|
209
|
+
# it does so by abandoning the example's annotation. Degrading HERE
|
|
210
|
+
# instead means ONE swallowed probe per suite rather than one per
|
|
211
|
+
# example, with `unverified_verdicts` as the single consistent answer
|
|
212
|
+
# for every file.
|
|
213
|
+
#
|
|
214
|
+
# Cached, including on failure, for the reason {#schema} is: the probe is
|
|
215
|
+
# a subprocess, and re-running it per example is the O(examples) cost this
|
|
216
|
+
# class exists to refuse — on exactly the unhappy path where somebody's CI
|
|
217
|
+
# is already having a bad day.
|
|
218
|
+
def backend
|
|
219
|
+
return @backend if defined?(@backend)
|
|
220
|
+
|
|
221
|
+
@backend = nil
|
|
222
|
+
@backend = ValidatorBackend.resolve(env: @env)
|
|
223
|
+
rescue ValidatorError
|
|
224
|
+
# Deliberately swallowed rather than re-raised: @backend is already nil,
|
|
225
|
+
# so #verdicts_for takes the unverified_verdicts arm and the memo keeps
|
|
226
|
+
# this from being re-probed.
|
|
227
|
+
@backend = nil
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# @return [Index]
|
|
231
|
+
def index_for(file)
|
|
232
|
+
return @indexes[file] if @indexes.key?(file)
|
|
233
|
+
|
|
234
|
+
# Cache the pessimistic answer *before* the scan, not after. If the scan
|
|
235
|
+
# raises, the raise still reaches the formatter (which warns once and
|
|
236
|
+
# moves on) but the empty index stays behind, so the next example in the
|
|
237
|
+
# same file is answered from the cache instead of re-raising and
|
|
238
|
+
# re-reading. Without this, "warns once" would still mean "re-reads a
|
|
239
|
+
# broken file once per example" — the O(examples) cost this class exists
|
|
240
|
+
# to remove, reappearing on exactly the unhappy path where somebody's CI
|
|
241
|
+
# is already having a bad day.
|
|
242
|
+
@indexes[file] = EMPTY_INDEX
|
|
243
|
+
@indexes[file] = build_index(file)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# @return [Index]
|
|
247
|
+
def build_index(file)
|
|
248
|
+
# The read happens on BOTH paths and first on both. The backend does not
|
|
249
|
+
# need it to find annotations — it reads the file itself — but the
|
|
250
|
+
# comment-form rule below is a property of the LINE an annotation sits
|
|
251
|
+
# on, which no report carries, so the text is required either way. Doing
|
|
252
|
+
# it first also keeps the two paths agreeing about an unreadable file
|
|
253
|
+
# without a subprocess being started to rediscover it.
|
|
254
|
+
text = read(file)
|
|
255
|
+
return EMPTY_INDEX if text.nil?
|
|
256
|
+
|
|
257
|
+
index_from(verdicts_for(file, text), text)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# One shell-out per FILE, which is the cost model this class already
|
|
261
|
+
# commits to: {#index_for} is O(files), `--source` takes files, and the
|
|
262
|
+
# port reports every annotation in one pass with the `file:line` scoping
|
|
263
|
+
# {Index} needs. Per-example would be O(examples) subprocesses, which is
|
|
264
|
+
# the cost this class exists to refuse.
|
|
265
|
+
#
|
|
266
|
+
# `#check` returns one {Linter::Result} per finding. A result that is not
|
|
267
|
+
# `ok?` is an annotation the linter fails the build over, so it ships
|
|
268
|
+
# nothing. Read failures and no-matches arrive line-scoped to 0 and are
|
|
269
|
+
# filtered by {#index_from}.
|
|
270
|
+
#
|
|
271
|
+
# A {ValidatorError} here means the backend could not answer about THIS
|
|
272
|
+
# file's annotations. Since the cutover there is no Ruby arm to fall back
|
|
273
|
+
# to, the honest answer is the one this class already gives for anything
|
|
274
|
+
# it could not verify: every annotation in the file ships `unannotated`.
|
|
275
|
+
# The never-block-CI contract forbids anything louder from the formatter,
|
|
276
|
+
# and the linter — which CAN be loud — is the tool that catches it.
|
|
277
|
+
def verdicts_for(file, text)
|
|
278
|
+
resolved = backend
|
|
279
|
+
return unverified_verdicts(file, text) if resolved.nil?
|
|
280
|
+
|
|
281
|
+
resolved.check([file]).map do |result|
|
|
282
|
+
[result.line, result.ok? ? result.representable_intent : nil]
|
|
283
|
+
end
|
|
284
|
+
rescue ValidatorError
|
|
285
|
+
unverified_verdicts(file, text)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# The lines the scanner still finds, every one answered `nil`. Discovery
|
|
289
|
+
# is a property of the FILE (which lines carry annotations, which are
|
|
290
|
+
# comment-form) and stays local — it is validation that needed the
|
|
291
|
+
# backend. Keeping the lines lets {#index_from} apply the comment-form
|
|
292
|
+
# rule exactly as it does for verdicts the backend did answer.
|
|
293
|
+
def unverified_verdicts(file, text)
|
|
294
|
+
Scanner.scan_text(text, file: file).map { |finding| [finding.line, nil] }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# @return [Index]
|
|
298
|
+
def index_from(verdicts, text)
|
|
299
|
+
lines = nil
|
|
300
|
+
own = {}
|
|
301
|
+
inheritable = {}
|
|
302
|
+
|
|
303
|
+
verdicts.each do |line, intent|
|
|
304
|
+
# Findings at line 0 are {Finding::KIND_READ} — "this file was never
|
|
305
|
+
# read", not "there is an annotation on line 0".
|
|
306
|
+
next unless line.is_a?(Integer) && line >= FIRST_REAL_LINE
|
|
307
|
+
|
|
308
|
+
# First wins. `each_intent` resumes scanning after each captured
|
|
309
|
+
# payload, so trailing prose containing a second `@intent:` yields a
|
|
310
|
+
# second finding on the same line. The annotation is the one the
|
|
311
|
+
# author wrote first; the rest of the line is commentary. Stated here
|
|
312
|
+
# rather than left to Hash-insertion order.
|
|
313
|
+
next if own.key?(line)
|
|
314
|
+
|
|
315
|
+
own[line] = intent
|
|
316
|
+
# Materialized only once a real annotation has been found, and never
|
|
317
|
+
# for a file `scan_text` refused: its invalid-UTF-8 answer is a single
|
|
318
|
+
# line-0 finding, filtered out above, and splitting such a string is
|
|
319
|
+
# itself an ArgumentError waiting to happen.
|
|
320
|
+
lines ||= text.lines
|
|
321
|
+
inheritable[line] = intent if COMMENT_LINE.match?(lines[line - 1].to_s)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
Index.new(own, inheritable)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# {Scanner.scan_file} would do this read, but it hands back only Findings
|
|
328
|
+
# and the form of an annotation is a property of the *line* it sits on. So
|
|
329
|
+
# the read happens here and {Scanner.scan_text} — the same pipeline, one
|
|
330
|
+
# stage lower — does the extracting. Nothing about what an annotation *is*
|
|
331
|
+
# is re-implemented; a second extractor is how the tool that tells an
|
|
332
|
+
# author their annotation is wrong and the tool that reports it to the
|
|
333
|
+
# platform end up disagreeing.
|
|
334
|
+
#
|
|
335
|
+
# The rescue mirrors `scan_file`'s own, and the two agree on the answer: it
|
|
336
|
+
# turns an unreadable file into a line-0 KIND_READ Finding, which this
|
|
337
|
+
# class filters out, and nil here becomes {EMPTY_INDEX}. Either way, every
|
|
338
|
+
# example in a file that could not be read is unannotated.
|
|
339
|
+
#
|
|
340
|
+
# @return [String, nil]
|
|
341
|
+
def read(file)
|
|
342
|
+
File.read(file, encoding: "UTF-8")
|
|
343
|
+
rescue SystemCallError, IOError
|
|
344
|
+
nil
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Keyed by the intent itself, so an annotation repeated across a suite is
|
|
348
|
+
# checked once rather than once per example that carries it. The
|
|
349
|
+
# shell-out is the expensive stage, and this class is explicitly about
|
|
350
|
+
# not paying O(examples) for work that is O(files).
|
|
351
|
+
#
|
|
352
|
+
# The verdict is now reached while the index is BUILT rather than when an
|
|
353
|
+
# example first claims the line, which makes the cost O(annotations in the
|
|
354
|
+
# file) instead of O(annotations examples ask about) — still bounded by
|
|
355
|
+
# the file, still memoized across files, and it is what lets the index
|
|
356
|
+
# hold one shape whichever path filled it. The visible consequence is that
|
|
357
|
+
# an unloadable schema raises on the first lookup into a file with any
|
|
358
|
+
# annotation rather than the first lookup that lands on one; both are
|
|
359
|
+
# inside the formatter's envelope, and {#index_for}'s pessimistic
|
|
360
|
+
# pre-caching already guarantees the second example does not raise again.
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecGuard
|
|
4
|
+
module RSpec
|
|
5
|
+
# Finds `@intent:` annotations in test source and captures the object
|
|
6
|
+
# literal that follows each one.
|
|
7
|
+
#
|
|
8
|
+
# This is a *syntactic* pass only: it hands back the payload exactly as
|
|
9
|
+
# written (still in PROTOCOL.md §1's permissive syntax — see
|
|
10
|
+
# {PayloadNormalizer}) and never inspects its contents. Schema validation is
|
|
11
|
+
# a later stage entirely.
|
|
12
|
+
#
|
|
13
|
+
# The scan is **string-aware**: quoted content is skipped over wholesale, so
|
|
14
|
+
# a `behavior` sentence containing an apostrophe or an unbalanced `{` cannot
|
|
15
|
+
# terminate the capture early. Plain brace-counting gets that case wrong.
|
|
16
|
+
#
|
|
17
|
+
# The algorithm is PROTOCOL.md §1's: find each `@intent:` token and capture
|
|
18
|
+
# the object literal after it, string-aware and bracket-balanced.
|
|
19
|
+
# open-test-intent's `validate-intent` implements the same rules; where the
|
|
20
|
+
# two disagree, PROTOCOL.md decides.
|
|
21
|
+
module AnnotationScanner
|
|
22
|
+
INTENT_TOKEN = "@intent:"
|
|
23
|
+
|
|
24
|
+
# Closing bracket => the opener it must match.
|
|
25
|
+
OPENERS = { "}" => "{", "]" => "[" }.freeze
|
|
26
|
+
|
|
27
|
+
# The `{` at `start` was never closed on this line.
|
|
28
|
+
UNTERMINATED_OBJECT = "unterminated object literal (an annotation must fit on one line)"
|
|
29
|
+
UNBALANCED_BRACKETS = "unbalanced brackets in the annotation payload"
|
|
30
|
+
NO_PAYLOAD = "no '{...}' object literal follows the @intent: token"
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# Yields `[line_no, raw_payload, problem]` for every `@intent:` token in
|
|
35
|
+
# `text`, in source order.
|
|
36
|
+
#
|
|
37
|
+
# `raw_payload` is the object literal as written; it is `nil` when
|
|
38
|
+
# `problem` explains why the annotation could not be captured.
|
|
39
|
+
#
|
|
40
|
+
# An `@intent:` token carrying no extractable payload is **reported, not
|
|
41
|
+
# skipped** — a typo'd annotation must fail loudly rather than silently
|
|
42
|
+
# counting as "this example is unannotated".
|
|
43
|
+
#
|
|
44
|
+
# @param text [String] the full source of one file
|
|
45
|
+
# @yieldparam line_no [Integer] 1-based line number
|
|
46
|
+
# @yieldparam raw_payload [String, nil]
|
|
47
|
+
# @yieldparam problem [String, nil]
|
|
48
|
+
# @return [Enumerator] when no block is given
|
|
49
|
+
def each_intent(text)
|
|
50
|
+
return enum_for(:each_intent, text) unless block_given?
|
|
51
|
+
|
|
52
|
+
text.each_line.with_index(1) do |raw_line, line_no|
|
|
53
|
+
line = raw_line.chomp
|
|
54
|
+
pos = 0
|
|
55
|
+
|
|
56
|
+
loop do
|
|
57
|
+
token_at = line.index(INTENT_TOKEN, pos)
|
|
58
|
+
break if token_at.nil?
|
|
59
|
+
|
|
60
|
+
brace_at = payload_brace(line, token_at)
|
|
61
|
+
if brace_at.nil?
|
|
62
|
+
yield line_no, nil, NO_PAYLOAD
|
|
63
|
+
break
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
begin
|
|
67
|
+
finish = scan_object(line, brace_at)
|
|
68
|
+
rescue ScanError => e
|
|
69
|
+
yield line_no, nil, e.message
|
|
70
|
+
break
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
yield line_no, line[brace_at...finish], nil
|
|
74
|
+
|
|
75
|
+
# Resume *after* the captured payload so trailing prose containing
|
|
76
|
+
# another `@intent:` is still seen, but the payload's own contents
|
|
77
|
+
# are never rescanned.
|
|
78
|
+
pos = finish
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returns the index of the `{` opening the payload of the `@intent:`
|
|
84
|
+
# token at `token_at`, or nil when that token has no payload.
|
|
85
|
+
#
|
|
86
|
+
# The search is **bounded by the next `@intent:` token on the line**. An
|
|
87
|
+
# unbounded search reads to end of line, so a malformed token followed by
|
|
88
|
+
# a well-formed one adopts its neighbour's object literal and is yielded
|
|
89
|
+
# with `problem: nil` — a typo'd annotation reported as valid, carrying
|
|
90
|
+
# somebody else's intent, and (because the caller resumes past the
|
|
91
|
+
# captured payload) swallowing the well-formed token on the way. That is
|
|
92
|
+
# the exact opposite of {each_intent}'s "reported, not skipped" contract,
|
|
93
|
+
# and PROTOCOL.md §1 supports the bounded reading: a payload is the object
|
|
94
|
+
# *following* its own token, which a literal on the far side of a second
|
|
95
|
+
# token is not.
|
|
96
|
+
#
|
|
97
|
+
# The bound can only ever *shrink* the search — it never picks a
|
|
98
|
+
# different brace, only declines one — so a token whose payload lies
|
|
99
|
+
# beyond the next token takes the {NO_PAYLOAD} path instead of a false
|
|
100
|
+
# pass.
|
|
101
|
+
#
|
|
102
|
+
# `line.index` is a naive string search, so it also finds an `@intent:`
|
|
103
|
+
# written inside a quoted string. That is harmless for the case it looks
|
|
104
|
+
# like it would break — a token quoted *inside a payload* is by definition
|
|
105
|
+
# after that payload's `{`, so the bound is inert and the payload is
|
|
106
|
+
# captured as before. The occurrence has to sit between the token and its
|
|
107
|
+
# `{` to matter at all, which means it is in the prose separating them:
|
|
108
|
+
#
|
|
109
|
+
# # @intent: like the "@intent:" above { entity: "Order", ... }
|
|
110
|
+
#
|
|
111
|
+
# That line now reports NO_PAYLOAD where it previously captured the
|
|
112
|
+
# literal. It is the one shape this bound makes stricter, and the
|
|
113
|
+
# stricter answer is the right one: the line is genuinely ambiguous about
|
|
114
|
+
# which token owns the literal, and declining to guess is the loud answer
|
|
115
|
+
# this scanner is supposed to give. Moving the quoted mention after the
|
|
116
|
+
# payload, or dropping the quotes, restores the capture.
|
|
117
|
+
def payload_brace(line, token_at)
|
|
118
|
+
after_token = token_at + INTENT_TOKEN.length
|
|
119
|
+
brace_at = line.index("{", after_token)
|
|
120
|
+
return nil if brace_at.nil?
|
|
121
|
+
|
|
122
|
+
next_token = line.index(INTENT_TOKEN, after_token)
|
|
123
|
+
return nil if next_token && brace_at > next_token
|
|
124
|
+
|
|
125
|
+
brace_at
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Returns the index just past the `}` matching the `{` at `text[start]`.
|
|
129
|
+
#
|
|
130
|
+
# Bracket-balanced and string-aware, so a brace inside a quoted value does
|
|
131
|
+
# not end the payload early. Annotations are single-line per PROTOCOL.md
|
|
132
|
+
# §1, so `text` is one line.
|
|
133
|
+
#
|
|
134
|
+
# @raise [ScanError] when the literal is unterminated or unbalanced
|
|
135
|
+
def scan_object(text, start)
|
|
136
|
+
stack = []
|
|
137
|
+
i = start
|
|
138
|
+
length = text.length
|
|
139
|
+
|
|
140
|
+
while i < length
|
|
141
|
+
char = text[i]
|
|
142
|
+
|
|
143
|
+
if char == '"' || char == "'"
|
|
144
|
+
i = scan_string(text, i, char)
|
|
145
|
+
next
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
if char == "{" || char == "["
|
|
149
|
+
stack.push(char)
|
|
150
|
+
elsif char == "}" || char == "]"
|
|
151
|
+
raise ScanError, UNBALANCED_BRACKETS if stack.empty? || stack.last != OPENERS[char]
|
|
152
|
+
|
|
153
|
+
stack.pop
|
|
154
|
+
return i + 1 if stack.empty?
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
i += 1
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
raise ScanError, UNTERMINATED_OBJECT
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Returns the index just past the closing `quote` of a string literal.
|
|
164
|
+
#
|
|
165
|
+
# `text[start]` must be the opening quote. Backslash escapes are honoured,
|
|
166
|
+
# so a quote or brace *inside* the string never terminates the scan.
|
|
167
|
+
#
|
|
168
|
+
# @raise [ScanError] when the string literal is unterminated
|
|
169
|
+
def scan_string(text, start, quote)
|
|
170
|
+
i = start + 1
|
|
171
|
+
length = text.length
|
|
172
|
+
|
|
173
|
+
while i < length
|
|
174
|
+
char = text[i]
|
|
175
|
+
|
|
176
|
+
if char == "\\"
|
|
177
|
+
i += 2 # skip the escape and whatever it escapes
|
|
178
|
+
next
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
return i + 1 if char == quote
|
|
182
|
+
|
|
183
|
+
i += 1
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
raise ScanError, "unterminated #{quote}-quoted string"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|