specguard-ruby 0.3.3 → 0.3.5
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 +4 -4
- data/README.md +6 -3
- data/lib/specguard/minitest/reporter.rb +54 -3
- data/lib/specguard/rspec/cli.rb +35 -9
- data/lib/specguard/rspec/file_selector.rb +31 -9
- data/lib/specguard/rspec/finding.rb +11 -0
- data/lib/specguard/rspec/linter.rb +10 -1
- data/lib/specguard/rspec/scanner.rb +101 -0
- data/lib/specguard/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9a82ff31aaa51455637e6500a2f4cebda11b50dad52947bd36ce535ada84bc1b
|
|
4
|
+
data.tar.gz: 658887600bd0fec337a1d7ddd372b3c481fa761c95ff3c92c25f12fc6e0fcf91
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 88589ddb1374aa792be0199049b7bb204b6479850831db80340344f4d624db96dc9b7c1c345d6c95c24bbb6728b4e711ab7a338899a7a815c09e6eb53f47c4b2
|
|
7
|
+
data.tar.gz: e11192524e6f248aa16d42c522664863a84894b85987f07ef5f54e5b9c2439bdb6146b0f3c693196d0fcc627f85923f24c5655ed330a9809926f1519802e1e0f
|
data/README.md
CHANGED
|
@@ -43,8 +43,9 @@ bundle exec ruby -rminitest/specguard_plugin -e 'Minitest.extensions << "specgua
|
|
|
43
43
|
## The linter — `specguard-lint`
|
|
44
44
|
|
|
45
45
|
Validates `# @intent:` annotations in changed (or all) `*_spec.rb` files against the OpenTestIntent
|
|
46
|
-
JSON Schema. Exits `1` on a malformed annotation
|
|
47
|
-
|
|
46
|
+
JSON Schema. Exits `1` on a malformed annotation — or a well-formed but unreachable one (stacked
|
|
47
|
+
above another comment-form `@intent:` line, so the one-line lookback never claims it) — and
|
|
48
|
+
**never** fails on a *missing* one (adoption is opt-in and gradual).
|
|
48
49
|
|
|
49
50
|
```bash
|
|
50
51
|
bundle exec specguard-lint --changed # CI mode: only files in the current diff
|
|
@@ -278,7 +279,9 @@ the `read` kind. The binary's own UTF-8 refusal prose (`input is not well-formed
|
|
|
278
279
|
|
|
279
280
|
Every way the backend can fail — the binary is missing, will not execute, exits with something that
|
|
280
281
|
is not a verdict, or emits output that is not a report — is **exit 2**, the linter's "could not do
|
|
281
|
-
my job" code. It never becomes exit 1, which means "an annotation is malformed"
|
|
282
|
+
my job" code. It never becomes exit 1, which means "an annotation is malformed" — or well-formed
|
|
283
|
+
but unreachable: stacked above another comment-form `@intent:` line, so the one-line lookback
|
|
284
|
+
never claims it — and nothing else.
|
|
282
285
|
|
|
283
286
|
## The formatter — `SpecGuard::RSpecFormatter`
|
|
284
287
|
|
|
@@ -10,6 +10,17 @@ require "fileutils"
|
|
|
10
10
|
# minitest-only consumer's machine.
|
|
11
11
|
require_relative "../rspec/configuration"
|
|
12
12
|
|
|
13
|
+
# The annotation lookup is the third framework-free half: what counts as a
|
|
14
|
+
# test's annotation is a property of the source line, not of the framework
|
|
15
|
+
# that ran it, so the Minitest reporter asks the same {AnnotationLookup} the
|
|
16
|
+
# RSpec formatter does rather than carrying a second extractor (whose header
|
|
17
|
+
# already warns that a second extractor guarantees scanner and reporter
|
|
18
|
+
# eventually disagree about what an annotation is). Requiring it pulls the
|
|
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
|
|
21
|
+
# stays loadable.
|
|
22
|
+
require_relative "../rspec/annotation_lookup"
|
|
23
|
+
|
|
13
24
|
# The Minitest half of `specguard-ruby`. Everything the RSpec formatter knows
|
|
14
25
|
# about the platform — the envelope's field names, the transport's
|
|
15
26
|
# never-raise contract, the switch that a missing key is — is framework-free
|
|
@@ -18,7 +29,10 @@ require_relative "../rspec/configuration"
|
|
|
18
29
|
# ship first, neither containing any RSpec). This adapter contributes only the
|
|
19
30
|
# part that is genuinely Minitest's: turning `Minitest::Result` objects into
|
|
20
31
|
# the same row shape the RSpec formatter emits, at the moment Minitest hands
|
|
21
|
-
# them to a reporter
|
|
32
|
+
# them to a reporter — and attaching each test's annotation the same way the
|
|
33
|
+
# formatter does, through the shared {SpecGuard::RSpec::AnnotationLookup} and
|
|
34
|
+
# its one-line lookback (SPGD-12 §2), so a Minitest row carries the same
|
|
35
|
+
# `status` / `intent` pair a RSpec row does.
|
|
22
36
|
#
|
|
23
37
|
# It is a duck-typed Minitest reporter (`start` / `record` / `report` /
|
|
24
38
|
# `passed?`) rather than a subclass of `Minitest::AbstractReporter`, because
|
|
@@ -36,6 +50,12 @@ require_relative "../rspec/configuration"
|
|
|
36
50
|
module SpecGuard
|
|
37
51
|
module Minitest
|
|
38
52
|
class Reporter
|
|
53
|
+
# `Ingest::Payload::STATUSES`, restated — the same call the RSpec
|
|
54
|
+
# formatter makes. The platform validates every row against this pair,
|
|
55
|
+
# so they are the contract and not a local naming choice.
|
|
56
|
+
STATUS_ANNOTATED = "annotated"
|
|
57
|
+
STATUS_UNANNOTATED = "unannotated"
|
|
58
|
+
|
|
39
59
|
# Rows with no usable location are dropped rather than mangled: a row
|
|
40
60
|
# whose file cannot be named fits no by-file aggregate on the platform
|
|
41
61
|
# and would surface as noise. The RSpec formatter's `capture` makes the
|
|
@@ -51,12 +71,14 @@ module SpecGuard
|
|
|
51
71
|
|
|
52
72
|
def initialize(configuration: SpecGuard::RSpec.configuration,
|
|
53
73
|
transport: nil, output: $stderr,
|
|
74
|
+
annotations: SpecGuard::RSpec::AnnotationLookup.new,
|
|
54
75
|
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
55
76
|
@configuration = configuration
|
|
56
77
|
# Injected for the specs; the real thing is built the same way the
|
|
57
78
|
# RSpec formatter builds it, from the same configuration object.
|
|
58
79
|
@transport = transport
|
|
59
80
|
@output = output
|
|
81
|
+
@annotations = annotations
|
|
60
82
|
@clock = clock
|
|
61
83
|
@rows = []
|
|
62
84
|
@started_at = nil
|
|
@@ -175,6 +197,8 @@ module SpecGuard
|
|
|
175
197
|
|
|
176
198
|
file = relative_path(file)
|
|
177
199
|
name = "#{result.klass}##{result.name}"
|
|
200
|
+
line_number = line&.to_i
|
|
201
|
+
intent = annotation_for(file, line_number)
|
|
178
202
|
{
|
|
179
203
|
# Minitest has no RSpec-style rerun path or nested ids, so the
|
|
180
204
|
# definition site is the identity: stable across runs (which is
|
|
@@ -183,7 +207,7 @@ module SpecGuard
|
|
|
183
207
|
"id" => "#{file}:#{line}",
|
|
184
208
|
"spec_file_path" => file,
|
|
185
209
|
"file_path" => file,
|
|
186
|
-
"line_number" =>
|
|
210
|
+
"line_number" => line_number,
|
|
187
211
|
"name" => name,
|
|
188
212
|
"duration" => result.time,
|
|
189
213
|
# The platform's three outcomes, mapped from Minitest's questions:
|
|
@@ -192,10 +216,37 @@ module SpecGuard
|
|
|
192
216
|
# Minitest distinguishes assertion failures from exceptions, the
|
|
193
217
|
# wire contract does not, and inventing a fourth outcome here would
|
|
194
218
|
# be a lie the schema refuses.
|
|
195
|
-
"outcome" => outcome_for(result)
|
|
219
|
+
"outcome" => outcome_for(result),
|
|
220
|
+
# The RSpec formatter's rationale, restated: the key is written
|
|
221
|
+
# explicitly because a present key says "we looked", where an absent
|
|
222
|
+
# key is indistinguishable from "this producer does not report
|
|
223
|
+
# annotations" — and without it the platform refuses the whole run
|
|
224
|
+
# (`Ingest::Payload` requires `status` on EVERY row). `intent` is
|
|
225
|
+
# likewise explicit: null when unannotated, which is exactly what
|
|
226
|
+
# the platform demands of an unannotated row.
|
|
227
|
+
"status" => intent.nil? ? STATUS_UNANNOTATED : STATUS_ANNOTATED,
|
|
228
|
+
"intent" => intent
|
|
196
229
|
}
|
|
197
230
|
end
|
|
198
231
|
|
|
232
|
+
# The lookup, inside its own envelope. `record`'s rescue would drop the
|
|
233
|
+
# whole row — example, duration, outcome and all — to learn one row's
|
|
234
|
+
# annotation, which is the trade the RSpec formatter already refused:
|
|
235
|
+
# its annotation lookup runs inside a nested guard so a blow-up costs
|
|
236
|
+
# the row only its annotation. The lookup degrades verdicts it could
|
|
237
|
+
# not reach to nil itself (an unusable validator ships unannotated by
|
|
238
|
+
# design); this envelope is for what escapes it anyway — file reads the
|
|
239
|
+
# lookup does not expect, encoding faults, anything else — and it
|
|
240
|
+
# routes through {#warn_once}, so the operator hears about it exactly
|
|
241
|
+
# once however many rows are affected.
|
|
242
|
+
def annotation_for(file, line)
|
|
243
|
+
@annotations.intent_for(file: file, line: line)
|
|
244
|
+
rescue ScriptError, StandardError => e
|
|
245
|
+
warn_once("could not resolve annotations (#{e.class}: #{e.message}). " \
|
|
246
|
+
"Rows continue unannotated. The test run is unaffected.")
|
|
247
|
+
nil
|
|
248
|
+
end
|
|
249
|
+
|
|
199
250
|
def outcome_for(result)
|
|
200
251
|
return "pending" if result.skipped?
|
|
201
252
|
return "failed" unless result.passed?
|
data/lib/specguard/rspec/cli.rb
CHANGED
|
@@ -9,7 +9,7 @@ module SpecGuard
|
|
|
9
9
|
# == The contract, and the reason it needs defending
|
|
10
10
|
#
|
|
11
11
|
# 0 every annotation checked is valid (including "there were none")
|
|
12
|
-
# 1 at least one annotation is malformed
|
|
12
|
+
# 1 at least one annotation is malformed or unreachable
|
|
13
13
|
# 2 the linter could not do its job — misuse, or the tool itself broken
|
|
14
14
|
#
|
|
15
15
|
# Ruby does not give you this for free; it actively works against it.
|
|
@@ -102,9 +102,11 @@ module SpecGuard
|
|
|
102
102
|
# Every annotation checked was valid — or there were none to check.
|
|
103
103
|
# "Lint, don't require": a missing annotation is never an error.
|
|
104
104
|
EXIT_OK = 0
|
|
105
|
-
# One or more annotations are malformed
|
|
106
|
-
#
|
|
107
|
-
#
|
|
105
|
+
# One or more annotations are malformed — or well-formed but
|
|
106
|
+
# unreachable (SPGD-900: stacked above another comment-form `@intent:`
|
|
107
|
+
# line, so the one-line lookback never claims it, a dead contract that
|
|
108
|
+
# is still a failure). The only code produced by inspecting content,
|
|
109
|
+
# and the only path that reaches it is a failed {Linter::Result}.
|
|
108
110
|
EXIT_MALFORMED = 1
|
|
109
111
|
# The linter could not do its job: bad flags, `--changed` outside a git
|
|
110
112
|
# repository, a validator that could not be resolved, or an unexpected
|
|
@@ -140,6 +142,14 @@ module SpecGuard
|
|
|
140
142
|
|
|
141
143
|
results = backend.check(selection.files)
|
|
142
144
|
|
|
145
|
+
# The structural pass (SPGD-900): annotations that are valid in
|
|
146
|
+
# isolation but can never be extracted — stacked consecutive
|
|
147
|
+
# comment-form `@intent:` lines, where the one-line lookback
|
|
148
|
+
# (SPGD-12 §2) claims only the line just above the example. These are
|
|
149
|
+
# Linter::Results like any other, so both renderers and the exit code
|
|
150
|
+
# pick them up with no second path to keep in step.
|
|
151
|
+
results += unreachable_results(selection.files)
|
|
152
|
+
|
|
143
153
|
# Computed once, here, and handed to whichever renderer runs. `--json`
|
|
144
154
|
# is a second renderer over this list, not a second code path: the exit
|
|
145
155
|
# code below is the same expression it always was, and the document's
|
|
@@ -162,6 +172,19 @@ module SpecGuard
|
|
|
162
172
|
|
|
163
173
|
private
|
|
164
174
|
|
|
175
|
+
# {Scanner}'s structural findings are annotations, not file problems:
|
|
176
|
+
# each carries a real `file:line`, so they become failing line-scoped
|
|
177
|
+
# {Linter::Result}s exactly like a malformed payload would. A file the
|
|
178
|
+
# backend could not read contributes nothing here — the backend's own
|
|
179
|
+
# KIND_READ result already reports it, and this pass cannot be positional
|
|
180
|
+
# about a file it never saw.
|
|
181
|
+
def unreachable_results(paths)
|
|
182
|
+
Scanner.unreachable_findings(paths).map do |finding|
|
|
183
|
+
Linter::Result.new(file: finding.file, line: finding.line,
|
|
184
|
+
kind: finding.kind, problem: finding.problem)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
165
188
|
# One line per run naming the implementation that produced the verdicts.
|
|
166
189
|
# Since the SPGD-867 cutover there is exactly one implementation — the
|
|
167
190
|
# resolved `validate-intent` binary — and the line says which one,
|
|
@@ -221,25 +244,28 @@ module SpecGuard
|
|
|
221
244
|
def empty_reason(selection)
|
|
222
245
|
case selection.mode
|
|
223
246
|
when :changed then changed_empty_reason(selection)
|
|
224
|
-
else "no *_spec.rb found under #{Dir.pwd}"
|
|
247
|
+
else "no *_spec.rb or *_test.rb file found under #{Dir.pwd}"
|
|
225
248
|
end
|
|
226
249
|
end
|
|
227
250
|
|
|
228
251
|
# Names the filter that actually emptied the selection. Saying "nothing in
|
|
229
|
-
# the diff matched
|
|
252
|
+
# the diff matched" when a test file demonstrably changed —
|
|
230
253
|
# just not under this directory — is worse than saying nothing: it reads
|
|
231
|
-
# as a conclusion and stops the reader looking.
|
|
254
|
+
# as a conclusion and stops the reader looking. Both naming conventions
|
|
255
|
+
# are named because either would have been selected; a Minitest-only
|
|
256
|
+
# repository must not be told the silence is about `*_spec.rb` files it
|
|
257
|
+
# does not have.
|
|
232
258
|
def changed_empty_reason(selection)
|
|
233
259
|
stats = selection.stats
|
|
234
260
|
base = selection.base
|
|
235
261
|
|
|
236
|
-
return "nothing in the diff against #{base} matched *_spec.rb" if stats.nil?
|
|
262
|
+
return "nothing in the diff against #{base} matched a *_spec.rb or *_test.rb file" if stats.nil?
|
|
237
263
|
|
|
238
264
|
if stats.changed.zero?
|
|
239
265
|
"nothing changed against #{base}"
|
|
240
266
|
elsif stats.spec_matches.zero?
|
|
241
267
|
"#{stats.changed} file#{'s' unless stats.changed == 1} changed against #{base}, " \
|
|
242
|
-
"none matching *_spec.rb"
|
|
268
|
+
"none matching *_spec.rb or *_test.rb"
|
|
243
269
|
else
|
|
244
270
|
changed_excluded_reason(stats, base)
|
|
245
271
|
end
|
|
@@ -6,8 +6,15 @@ module SpecGuard
|
|
|
6
6
|
module RSpec
|
|
7
7
|
# Chooses which spec files the linter reads.
|
|
8
8
|
#
|
|
9
|
-
# Two modes: every
|
|
10
|
-
# only those in the current diff (`--changed`).
|
|
9
|
+
# Two modes: every Ruby test file under the working directory (the
|
|
10
|
+
# default), or only those in the current diff (`--changed`). Both modes
|
|
11
|
+
# recognize the two Ruby test-framework naming conventions — RSpec's
|
|
12
|
+
# `*_spec.rb` and Minitest's `*_test.rb` — so a suite is visible to the
|
|
13
|
+
# linter whichever framework wrote it. The default walk scopes Minitest
|
|
14
|
+
# files to `test/` (the directory both Rails and `minitest` own as the
|
|
15
|
+
# convention), while `--changed` filters on the file-name suffix alone: a
|
|
16
|
+
# diff names paths, not directories, and a changed test file is a test
|
|
17
|
+
# file wherever the author put it.
|
|
11
18
|
#
|
|
12
19
|
# == Why `--changed` is not `git diff --name-only`
|
|
13
20
|
#
|
|
@@ -41,9 +48,10 @@ module SpecGuard
|
|
|
41
48
|
# found no annotations" from "checked 0 files" — {Selection} carries the
|
|
42
49
|
# count, the emptiness, and {Stats} explaining *which* filter emptied it, so
|
|
43
50
|
# the CLI can say so on stderr, accurately. A confidently wrong reason is
|
|
44
|
-
# worse than a quiet one: a human who reads "nothing in the diff matched
|
|
45
|
-
#
|
|
46
|
-
#
|
|
51
|
+
# worse than a quiet one: a human who reads "nothing in the diff matched a
|
|
52
|
+
# test file" stops looking — and on a Minitest-only repository the silence
|
|
53
|
+
# must not be explained in RSpec vocabulary the tree does not use. The exit
|
|
54
|
+
# code is not the lever: the spec fixes 0 for "no annotations".
|
|
47
55
|
#
|
|
48
56
|
# == Scope: `--changed` selects changed specs **under `root`**
|
|
49
57
|
#
|
|
@@ -74,7 +82,20 @@ module SpecGuard
|
|
|
74
82
|
# clean checkout of a commit) that cannot arise; locally the loud empty
|
|
75
83
|
# selection is what surfaces it.
|
|
76
84
|
module FileSelector
|
|
77
|
-
|
|
85
|
+
# `test/**/*_test.rb` rather than `**/*_test.rb`: the `test/` directory
|
|
86
|
+
# is where both Rails and Minitest itself put Minitest files, and
|
|
87
|
+
# keeping the second convention scoped stops the walk from adopting
|
|
88
|
+
# unrelated `*_test.rb` files elsewhere in the tree (fixture generators,
|
|
89
|
+
# vendored code). `*_spec.rb` stays unscoped because `spec/` already has
|
|
90
|
+
# no competitor convention to fence against.
|
|
91
|
+
DEFAULT_GLOB = ["**/*_spec.rb", "test/**/*_test.rb"].freeze
|
|
92
|
+
|
|
93
|
+
# The `--changed` counterparts of {DEFAULT_GLOB}: git hands back paths,
|
|
94
|
+
# so the filter is a match over the whole path rather than a glob walk,
|
|
95
|
+
# and the suffix alone decides. Deliberately not directory-scoped — a
|
|
96
|
+
# changed Minitest file outside `test/` is still this client's business
|
|
97
|
+
# in the one mode that answers "what did this branch touch".
|
|
98
|
+
CHANGED_PATTERNS = ["*_spec.rb", "*_test.rb"].freeze
|
|
78
99
|
|
|
79
100
|
# Ordered probes for the default branch when no explicit base is given.
|
|
80
101
|
DEFAULT_BRANCH_REFS = %w[origin/HEAD origin/main origin/master main master].freeze
|
|
@@ -116,8 +137,9 @@ module SpecGuard
|
|
|
116
137
|
changed ? select_changed(base: base, root: root) : select_all(root: root)
|
|
117
138
|
end
|
|
118
139
|
|
|
119
|
-
# Every
|
|
120
|
-
#
|
|
140
|
+
# Every test file under `root`, recursively, in either naming
|
|
141
|
+
# convention. Hidden directories are not traversed (no
|
|
142
|
+
# `File::FNM_DOTMATCH`), so `.git` and friends are skipped.
|
|
121
143
|
def select_all(root: Dir.pwd)
|
|
122
144
|
files = Dir.glob(DEFAULT_GLOB, base: root).select { |f| File.file?(File.join(root, f)) }.sort
|
|
123
145
|
Selection.new(files: files, mode: :all)
|
|
@@ -146,7 +168,7 @@ module SpecGuard
|
|
|
146
168
|
# @return [[Array<String>, Stats]]
|
|
147
169
|
def changed_files(base, root)
|
|
148
170
|
names = diff_names(base, root)
|
|
149
|
-
specs = names.select { |name| File.fnmatch?(
|
|
171
|
+
specs = names.select { |name| CHANGED_PATTERNS.any? { |pattern| File.fnmatch?(pattern, name) } }
|
|
150
172
|
|
|
151
173
|
top = toplevel(root)
|
|
152
174
|
prefix = directory_prefix(top.empty? ? root : top)
|
|
@@ -48,6 +48,17 @@ module SpecGuard
|
|
|
48
48
|
# of ways an annotation can fail is written down in one place.
|
|
49
49
|
KIND_SCHEMA = :schema
|
|
50
50
|
|
|
51
|
+
# The annotation is well-formed in isolation but can never be EXTRACTED:
|
|
52
|
+
# it is a comment-form `@intent:` on a line whose next line is also a
|
|
53
|
+
# comment-form `@intent:`, so the one-line lookback (SPGD-12 §2 —
|
|
54
|
+
# {AnnotationLookup} claims only the comment on the line immediately
|
|
55
|
+
# above an example) always skips it in favour of the lower line. The
|
|
56
|
+
# contract is dead metadata: counted by the linter, discarded by
|
|
57
|
+
# extraction. Produced by {Scanner}'s structural pass, not by
|
|
58
|
+
# {AnnotationScanner} — each annotation is scanned in isolation there,
|
|
59
|
+
# which is exactly why this defect is invisible to it.
|
|
60
|
+
KIND_UNREACHABLE = :unreachable
|
|
61
|
+
|
|
51
62
|
def initialize(file:, line:, intent: nil, problem: nil, kind: nil)
|
|
52
63
|
super
|
|
53
64
|
end
|
|
@@ -19,7 +19,8 @@ module SpecGuard
|
|
|
19
19
|
#
|
|
20
20
|
# == What counts as a failure
|
|
21
21
|
#
|
|
22
|
-
# Three things
|
|
22
|
+
# Three things make an annotation malformed, reaching the same verdict
|
|
23
|
+
# from different directions:
|
|
23
24
|
#
|
|
24
25
|
# * the payload could not be captured off the line at all
|
|
25
26
|
# (`Finding::KIND_EXTRACTION`) — a typo'd annotation;
|
|
@@ -35,6 +36,14 @@ module SpecGuard
|
|
|
35
36
|
# valid UTF-8 — is also reported as a failure, and therefore also exits 1.
|
|
36
37
|
# That matches `validate-intent`, which classifies it separately (its own
|
|
37
38
|
# `read` kind) and still reports it as `FAIL` with exit 1.
|
|
39
|
+
#
|
|
40
|
+
# `Finding::KIND_UNREACHABLE` — an annotation that is well-formed in
|
|
41
|
+
# isolation but stacked above another comment-form `@intent:` line, so
|
|
42
|
+
# the one-line lookback (SPGD-12 §2) never claims it — is also reported
|
|
43
|
+
# as a failure, and therefore also exits 1. Nothing about it is
|
|
44
|
+
# malformed; the contract is dead metadata, counted by the linter and
|
|
45
|
+
# discarded by extraction, and the structural pass that flags it
|
|
46
|
+
# (SPGD-900) reports it loudly rather than letting it pass as clean.
|
|
38
47
|
module Linter
|
|
39
48
|
# One annotation's verdict. `problem` is set when discovery could not
|
|
40
49
|
# produce an intent at all; `reasons` when the schema rejected one.
|
|
@@ -230,6 +230,107 @@ module SpecGuard
|
|
|
230
230
|
Finding.new(file: file, line: line, kind: Finding::KIND_PARSE,
|
|
231
231
|
problem: "could not parse annotation: #{e.message}")
|
|
232
232
|
end
|
|
233
|
+
|
|
234
|
+
# == The structural pass: annotations the extraction can never claim
|
|
235
|
+
#
|
|
236
|
+
# Everything above validates each annotation IN ISOLATION — one payload,
|
|
237
|
+
# one verdict. But the formatter's extraction contract
|
|
238
|
+
# ({AnnotationLookup}, SPGD-12 §2) is positional: only the comment-form
|
|
239
|
+
# annotation on the line IMMEDIATELY ABOVE an example is inheritable.
|
|
240
|
+
# So when two consecutive comment-form `@intent:` lines sit above one
|
|
241
|
+
# `it`, the UPPER line is unreachable — silently discarded by the
|
|
242
|
+
# one-line lookback — while this pipeline counts it as a valid
|
|
243
|
+
# annotation and the run exits 0. Measured (SPGD-897 audit): 16 such
|
|
244
|
+
# stacked pairs shipped through `specguard-lint` exit 0, every upper
|
|
245
|
+
# contract dead. Reported loudly, not skipped — the same stance
|
|
246
|
+
# {AnnotationScanner} takes for NO_PAYLOAD.
|
|
247
|
+
#
|
|
248
|
+
# A comment-form annotation line is a comment-only line
|
|
249
|
+
# ({AnnotationLookup::COMMENT_LINE}) carrying the `@intent:` token. The
|
|
250
|
+
# trailing same-line form never matches COMMENT_LINE, so it is never
|
|
251
|
+
# flagged and never makes a neighbour unreachable — its annotation
|
|
252
|
+
# belongs to its own example's line and the lookback is not involved.
|
|
253
|
+
#
|
|
254
|
+
# @param paths [Enumerable<String>]
|
|
255
|
+
# @return [Array<Finding>] one per unreachable comment-form annotation,
|
|
256
|
+
# in file-then-line order. A file that cannot be read contributes
|
|
257
|
+
# nothing here — {ValidatorBackend} already reports read failures, and
|
|
258
|
+
# this pass has nothing positional to say about a file it never saw.
|
|
259
|
+
def unreachable_findings(paths)
|
|
260
|
+
paths.flat_map { |path| unreachable_findings_in_file(path) }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# @param path [String]
|
|
264
|
+
# @return [Array<Finding>]
|
|
265
|
+
def unreachable_findings_in_file(path)
|
|
266
|
+
begin
|
|
267
|
+
text = File.read(path, encoding: "UTF-8")
|
|
268
|
+
rescue SystemCallError, IOError
|
|
269
|
+
return []
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
unreachable_findings_in_text(text, file: path)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
UNREACHABLE_ANNOTATION =
|
|
276
|
+
"unreachable annotation: the previous line is also a comment-form @intent:, " \
|
|
277
|
+
"so the one-line lookback claims only the line just above the example " \
|
|
278
|
+
"and this annotation is silently discarded at extraction (SPGD-12 §2)"
|
|
279
|
+
|
|
280
|
+
# A comment-only line carrying an `@intent:` token — the only form an
|
|
281
|
+
# example on the NEXT line may claim ({AnnotationLookup::COMMENT_LINE}).
|
|
282
|
+
COMMENT_INTENT_LINE = /\A\s*#.*@intent:/
|
|
283
|
+
|
|
284
|
+
# The line an annotation run may be claimed from: the first example
|
|
285
|
+
# keyword. The lookback rule is `example.metadata[:line_number] - 1`, so
|
|
286
|
+
# only the comment form on the line immediately above an example is
|
|
287
|
+
# inheritable — which makes an EXAMPLE the anchor of the whole rule.
|
|
288
|
+
#
|
|
289
|
+
# Anchoring on the example is what keeps this pass off annotation
|
|
290
|
+
# corpora with no examples at all (the recorded-binary fixtures in
|
|
291
|
+
# spec/fixtures/): there is no lookback there to silently discard
|
|
292
|
+
# anything, so there is nothing to report. The defect this pass exists
|
|
293
|
+
# for (SPGD-897) is stacked lines above a real example — an annotation
|
|
294
|
+
# the author believed was attached to the `it` under it.
|
|
295
|
+
EXAMPLE_LINE = /\A\s*(?:it|specify)\b/
|
|
296
|
+
|
|
297
|
+
# @param text [String] source of one file
|
|
298
|
+
# @param file [String] path to record on each Finding
|
|
299
|
+
# @return [Array<Finding>] one per comment-form `@intent:` line in a
|
|
300
|
+
# stacked run — a maximal run of consecutive comment-form `@intent:`
|
|
301
|
+
# lines immediately above an example — except the run's LAST line,
|
|
302
|
+
# which is the one the one-line lookback claims. For the canonical
|
|
303
|
+
# two-line stack above one `it`, that is the UPPER line.
|
|
304
|
+
def unreachable_findings_in_text(text, file:)
|
|
305
|
+
# A file that is not valid UTF-8 is reported once, loudly, by the
|
|
306
|
+
# backend as a read failure; nothing positional can be said about it,
|
|
307
|
+
# and `lines` below would raise on the invalid bytes.
|
|
308
|
+
return [] unless text.valid_encoding?
|
|
309
|
+
|
|
310
|
+
lines = text.lines
|
|
311
|
+
i = 0
|
|
312
|
+
findings = []
|
|
313
|
+
|
|
314
|
+
while i < lines.length
|
|
315
|
+
unless COMMENT_INTENT_LINE.match?(lines[i])
|
|
316
|
+
i += 1
|
|
317
|
+
next
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
run_start = i
|
|
321
|
+
i += 1 while i < lines.length && COMMENT_INTENT_LINE.match?(lines[i])
|
|
322
|
+
run_end = i # exclusive; lines[run_end] is the line after the run
|
|
323
|
+
|
|
324
|
+
if run_end < lines.length && EXAMPLE_LINE.match?(lines[run_end]) && run_start < run_end - 1
|
|
325
|
+
(run_start...(run_end - 1)).each do |j|
|
|
326
|
+
findings << Finding.new(file: file, line: j + 1, problem: UNREACHABLE_ANNOTATION,
|
|
327
|
+
kind: Finding::KIND_UNREACHABLE)
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
findings
|
|
333
|
+
end
|
|
233
334
|
end
|
|
234
335
|
end
|
|
235
336
|
end
|
data/lib/specguard/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.3.5
|
|
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-
|
|
11
|
+
date: 2026-09-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: json
|