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.
data/Rakefile ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ # The fixtures under spec/fixtures/ are linter *input* that happens to be named
7
+ # `*_spec.rb` — exactly what RSpec's default pattern loads. `.rspec` excludes
8
+ # them, and that is the source of truth for a bare `rspec` run.
9
+ #
10
+ # The rake task has to repeat it. RSpec::Core::RakeTask always puts an explicit
11
+ # `--pattern` on the command line, and a command-line `--pattern` discards the
12
+ # `--exclude-pattern` that came from `.rspec` — so the stock task loads the
13
+ # fixtures as examples and the suite dies with
14
+ # "cannot load such file -- rails_helper". Passing the exclusion on the command
15
+ # line too is what makes the two invocations agree. (Clearing `t.pattern`
16
+ # instead does not work: the task then passes an empty argument and matches
17
+ # nothing.)
18
+ #
19
+ # Keep this in step with `.rspec` — `rake` and `rspec` must select the same
20
+ # files. spec/spec_helper.rb fails the suite loudly if a fixture ever does get
21
+ # loaded as an example.
22
+ RSpec::Core::RakeTask.new(:spec) do |t|
23
+ t.rspec_opts = %(--exclude-pattern "spec/fixtures/**/*_spec.rb")
24
+ end
25
+
26
+ # `rake` with no arguments used to do nothing at all, which made it a useless
27
+ # CI entrypoint. It now means "run the suite".
28
+ task default: %i[spec]
Binary file
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # specguard-lint — the SpecGuard annotation linter entrypoint.
5
+ #
6
+ # Selects spec files, finds every `@intent:` annotation in them, validates each
7
+ # against the vendored OpenTestIntent schema, and reports every violation. Exit
8
+ # codes are the contract (SPGD-12 §1):
9
+ #
10
+ # 0 all annotations valid, or there were none ("lint, don't require")
11
+ # 1 one or more annotations are malformed
12
+ # 2 the linter could not do its job — misuse, or the tool itself broken
13
+ #
14
+ # SpecGuard::RSpec::CLI#run is written to *return* one of those three and never
15
+ # to raise; see its class comment for why that takes deliberate effort in Ruby.
16
+ # The `require` below is the one thing outside its reach, so it gets the same
17
+ # treatment here: a gem that cannot even load is the linter being broken, which
18
+ # is a 2. Left bare, Ruby would exit 1 and CI would report a malformed
19
+ # annotation nobody wrote.
20
+
21
+ # Allow running from a source checkout without bundler by putting the local
22
+ # lib/ on the load path. When the gem is installed (or run under
23
+ # `bundle exec`), specguard/rspec resolves through the normal load path.
24
+ source_lib = File.expand_path("../lib", __dir__)
25
+ $LOAD_PATH.unshift(source_lib) unless $LOAD_PATH.include?(source_lib)
26
+
27
+ begin
28
+ require "specguard/rspec"
29
+ rescue ScriptError, StandardError => e
30
+ warn "specguard-lint: error: could not load specguard-rspec: #{e.class}: #{e.message}"
31
+ exit 2
32
+ end
33
+
34
+ exit SpecGuard::RSpec::CLI.new.run(ARGV)
@@ -0,0 +1,286 @@
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
+ # {SpecGuard::RSpec::Schema} 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
+ # A line's own annotation always wins: the trailing form is written *on*
121
+ # the example, so when a file somehow carries both it is the more specific
122
+ # of the two, and a malformed trailing annotation is not quietly
123
+ # backfilled from the comment above it — that would attach an intent its
124
+ # author had already stopped meaning.
125
+ #
126
+ # `line - 1` is 0 for an example on line 1, and {AnnotationLookup#build_index}
127
+ # is what guarantees that reads back as "no annotation": line 0 is never a
128
+ # key, because the only Finding produced there is the KIND_READ sentinel.
129
+ Index = Struct.new(:own, :inheritable) do
130
+ # @return [Finding, nil]
131
+ def finding_for(line)
132
+ own[line] || inheritable[line - 1]
133
+ end
134
+ end
135
+
136
+ # The answer for a file nothing could be read out of. Its own constant so
137
+ # the pessimistic pre-scan cache in {#index_for} and the unreadable-file
138
+ # path cannot drift apart.
139
+ EMPTY_INDEX = Index.new({}.freeze, {}.freeze).freeze
140
+
141
+ # @param schema_path [String] the vendored OpenTestIntent schema. Injected
142
+ # so a spec can point at a broken one without stubbing the loader.
143
+ def initialize(schema_path: SCHEMA_PATH)
144
+ @schema_path = schema_path
145
+ @indexes = {}
146
+ @verdicts = {}
147
+ end
148
+
149
+ # The intent to attach to one example, or nil when it is unannotated.
150
+ #
151
+ # Nothing here is rescued: a caller that cannot survive an exception must
152
+ # say so itself, and the formatter does — it wraps this in the same
153
+ # `never_fail_the_run` envelope as everything else, so a blow-up costs the
154
+ # example its annotation and not the suite its exit code. The memoization
155
+ # below is deliberately written so that a failure is cached too, which is
156
+ # what keeps "warns once" from becoming "warns once but rescans the file
157
+ # for every one of the remaining examples".
158
+ #
159
+ # @param file [String, nil] the example's file, as the payload records it
160
+ # @param line [Integer, nil] `example.metadata[:line_number]`
161
+ # @return [Hash, nil] the parsed, schema-valid annotation
162
+ def intent_for(file:, line:)
163
+ finding = finding_for(file: file, line: line)
164
+
165
+ # {Finding#extracted?} is the discovery layer's own word for "this
166
+ # yielded a Hash" — and, pointedly, "not a claim that it is valid".
167
+ # Everything it excludes (KIND_EXTRACTION, KIND_PARSE, KIND_READ) is an
168
+ # annotation the linter fails the build over and this half must not.
169
+ return nil unless finding&.extracted?
170
+
171
+ validated(finding.intent)
172
+ end
173
+
174
+ private
175
+
176
+ def finding_for(file:, line:)
177
+ return nil unless file.is_a?(String) && !file.empty?
178
+ return nil unless line.is_a?(Integer) && line >= FIRST_REAL_LINE
179
+
180
+ index_for(file).finding_for(line)
181
+ end
182
+
183
+ # @return [Index]
184
+ def index_for(file)
185
+ return @indexes[file] if @indexes.key?(file)
186
+
187
+ # Cache the pessimistic answer *before* the scan, not after. If the scan
188
+ # raises, the raise still reaches the formatter (which warns once and
189
+ # moves on) but the empty index stays behind, so the next example in the
190
+ # same file is answered from the cache instead of re-raising and
191
+ # re-reading. Without this, "warns once" would still mean "re-reads a
192
+ # broken file once per example" — the O(examples) cost this class exists
193
+ # to remove, reappearing on exactly the unhappy path where somebody's CI
194
+ # is already having a bad day.
195
+ @indexes[file] = EMPTY_INDEX
196
+ @indexes[file] = build_index(file)
197
+ end
198
+
199
+ # @return [Index]
200
+ def build_index(file)
201
+ text = read(file)
202
+ return EMPTY_INDEX if text.nil?
203
+
204
+ lines = nil
205
+ own = {}
206
+ inheritable = {}
207
+
208
+ Scanner.scan_text(text, file: file).each do |finding|
209
+ line = finding.line
210
+ next unless line.is_a?(Integer) && line >= FIRST_REAL_LINE
211
+
212
+ # First wins. `each_intent` resumes scanning after each captured
213
+ # payload, so trailing prose containing a second `@intent:` yields a
214
+ # second Finding on the same line. The annotation is the one the
215
+ # author wrote first; the rest of the line is commentary. Stated here
216
+ # rather than left to Hash-insertion order.
217
+ next if own.key?(line)
218
+
219
+ own[line] = finding
220
+ # Materialized only once a real annotation has been found, and never
221
+ # for a file `scan_text` refused: its invalid-UTF-8 answer is a single
222
+ # line-0 Finding, filtered out above, and splitting such a string is
223
+ # itself an ArgumentError waiting to happen.
224
+ lines ||= text.lines
225
+ inheritable[line] = finding if COMMENT_LINE.match?(lines[line - 1].to_s)
226
+ end
227
+
228
+ Index.new(own, inheritable)
229
+ end
230
+
231
+ # {Scanner.scan_file} would do this read, but it hands back only Findings
232
+ # and the form of an annotation is a property of the *line* it sits on. So
233
+ # the read happens here and {Scanner.scan_text} — the same pipeline, one
234
+ # stage lower — does the extracting. Nothing about what an annotation *is*
235
+ # is re-implemented; a second extractor is how the tool that tells an
236
+ # author their annotation is wrong and the tool that reports it to the
237
+ # platform end up disagreeing.
238
+ #
239
+ # The rescue mirrors `scan_file`'s own, and the two agree on the answer: it
240
+ # turns an unreadable file into a line-0 KIND_READ Finding, which this
241
+ # class filters out, and nil here becomes {EMPTY_INDEX}. Either way, every
242
+ # example in a file that could not be read is unannotated.
243
+ #
244
+ # @return [String, nil]
245
+ def read(file)
246
+ File.read(file, encoding: "UTF-8")
247
+ rescue SystemCallError, IOError
248
+ nil
249
+ end
250
+
251
+ # Keyed by the intent itself, so an annotation repeated across a suite is
252
+ # compiled against the schema once rather than once per example that
253
+ # carries it. json_schemer is not free, and this class is explicitly about
254
+ # not paying O(examples) for work that is O(annotations).
255
+ #
256
+ # @return [Hash, nil] the intent when the schema accepts it
257
+ def validated(intent)
258
+ return @verdicts[intent] if @verdicts.key?(intent)
259
+
260
+ @verdicts[intent] = validate(intent)
261
+ end
262
+
263
+ def validate(intent)
264
+ loaded = schema
265
+ return nil if loaded.nil?
266
+
267
+ loaded.violations(intent).empty? ? intent : nil
268
+ end
269
+
270
+ # Loaded on first use, and at most once — including when loading fails.
271
+ #
272
+ # {Schema.load} raises {SchemaError} when the vendored document cannot be
273
+ # read, parsed or compiled. Under the linter that is a deliberate exit 2.
274
+ # Here it must not be an exit anything, so the raise is left to the
275
+ # formatter's envelope and the nil that gets cached in its place turns
276
+ # every subsequent annotation into an honest `unannotated` — the same
277
+ # answer this class gives for every other thing it could not verify.
278
+ def schema
279
+ return @schema if defined?(@schema)
280
+
281
+ @schema = nil
282
+ @schema = Schema.load(@schema_path)
283
+ end
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,144 @@
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
+ # Ported from open-test-intent's `bin/validate-intent`
18
+ # (`extract_intents` / `_scan_object` / `_scan_string`), which is the
19
+ # reference implementation of this algorithm.
20
+ module AnnotationScanner
21
+ INTENT_TOKEN = "@intent:"
22
+
23
+ # Closing bracket => the opener it must match.
24
+ OPENERS = { "}" => "{", "]" => "[" }.freeze
25
+
26
+ # The `{` at `start` was never closed on this line.
27
+ UNTERMINATED_OBJECT = "unterminated object literal (an annotation must fit on one line)"
28
+ UNBALANCED_BRACKETS = "unbalanced brackets in the annotation payload"
29
+ NO_PAYLOAD = "no '{...}' object literal follows the @intent: token"
30
+
31
+ module_function
32
+
33
+ # Yields `[line_no, raw_payload, problem]` for every `@intent:` token in
34
+ # `text`, in source order.
35
+ #
36
+ # `raw_payload` is the object literal as written; it is `nil` when
37
+ # `problem` explains why the annotation could not be captured.
38
+ #
39
+ # An `@intent:` token carrying no extractable payload is **reported, not
40
+ # skipped** — a typo'd annotation must fail loudly rather than silently
41
+ # counting as "this example is unannotated".
42
+ #
43
+ # @param text [String] the full source of one file
44
+ # @yieldparam line_no [Integer] 1-based line number
45
+ # @yieldparam raw_payload [String, nil]
46
+ # @yieldparam problem [String, nil]
47
+ # @return [Enumerator] when no block is given
48
+ def each_intent(text)
49
+ return enum_for(:each_intent, text) unless block_given?
50
+
51
+ text.each_line.with_index(1) do |raw_line, line_no|
52
+ line = raw_line.chomp
53
+ pos = 0
54
+
55
+ loop do
56
+ token_at = line.index(INTENT_TOKEN, pos)
57
+ break if token_at.nil?
58
+
59
+ brace_at = line.index("{", token_at + INTENT_TOKEN.length)
60
+ if brace_at.nil?
61
+ yield line_no, nil, NO_PAYLOAD
62
+ break
63
+ end
64
+
65
+ begin
66
+ finish = scan_object(line, brace_at)
67
+ rescue ScanError => e
68
+ yield line_no, nil, e.message
69
+ break
70
+ end
71
+
72
+ yield line_no, line[brace_at...finish], nil
73
+
74
+ # Resume *after* the captured payload so trailing prose containing
75
+ # another `@intent:` is still seen, but the payload's own contents
76
+ # are never rescanned.
77
+ pos = finish
78
+ end
79
+ end
80
+ end
81
+
82
+ # Returns the index just past the `}` matching the `{` at `text[start]`.
83
+ #
84
+ # Bracket-balanced and string-aware, so a brace inside a quoted value does
85
+ # not end the payload early. Annotations are single-line per PROTOCOL.md
86
+ # §1, so `text` is one line.
87
+ #
88
+ # @raise [ScanError] when the literal is unterminated or unbalanced
89
+ def scan_object(text, start)
90
+ stack = []
91
+ i = start
92
+ length = text.length
93
+
94
+ while i < length
95
+ char = text[i]
96
+
97
+ if char == '"' || char == "'"
98
+ i = scan_string(text, i, char)
99
+ next
100
+ end
101
+
102
+ if char == "{" || char == "["
103
+ stack.push(char)
104
+ elsif char == "}" || char == "]"
105
+ raise ScanError, UNBALANCED_BRACKETS if stack.empty? || stack.last != OPENERS[char]
106
+
107
+ stack.pop
108
+ return i + 1 if stack.empty?
109
+ end
110
+
111
+ i += 1
112
+ end
113
+
114
+ raise ScanError, UNTERMINATED_OBJECT
115
+ end
116
+
117
+ # Returns the index just past the closing `quote` of a string literal.
118
+ #
119
+ # `text[start]` must be the opening quote. Backslash escapes are honoured,
120
+ # so a quote or brace *inside* the string never terminates the scan.
121
+ #
122
+ # @raise [ScanError] when the string literal is unterminated
123
+ def scan_string(text, start, quote)
124
+ i = start + 1
125
+ length = text.length
126
+
127
+ while i < length
128
+ char = text[i]
129
+
130
+ if char == "\\"
131
+ i += 2 # skip the escape and whatever it escapes
132
+ next
133
+ end
134
+
135
+ return i + 1 if char == quote
136
+
137
+ i += 1
138
+ end
139
+
140
+ raise ScanError, "unterminated #{quote}-quoted string"
141
+ end
142
+ end
143
+ end
144
+ end