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,276 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module SpecGuard
|
|
6
|
+
module RSpec
|
|
7
|
+
# Chooses which spec files the linter reads.
|
|
8
|
+
#
|
|
9
|
+
# Two modes: every `*_spec.rb` under the working directory (the default), or
|
|
10
|
+
# only those in the current diff (`--changed`).
|
|
11
|
+
#
|
|
12
|
+
# == Why `--changed` is not `git diff --name-only`
|
|
13
|
+
#
|
|
14
|
+
# The Client Gem spec words `--changed` as "files in the current diff (via
|
|
15
|
+
# `git diff --name-only`)". Taken literally that is **broken in CI**: bare
|
|
16
|
+
# `git diff --name-only` compares the *working tree against the index*, and
|
|
17
|
+
# CI checks out a commit and leaves the tree clean. It therefore matches
|
|
18
|
+
# nothing, the linter selects zero files, finds zero annotations, and exits
|
|
19
|
+
# 0 — the CI gate the tool exists to provide, silently a no-op.
|
|
20
|
+
#
|
|
21
|
+
# So the diff base is an explicit decision, made here and documented here
|
|
22
|
+
# rather than inherited by accident:
|
|
23
|
+
#
|
|
24
|
+
# * `--changed` diffs against the **merge base with the default branch**
|
|
25
|
+
# (`origin/HEAD`, falling back to `origin/main`/`origin/master` and then
|
|
26
|
+
# their local counterparts). On a feature branch that is exactly "what
|
|
27
|
+
# this branch changed", whether or not the change is committed yet —
|
|
28
|
+
# `git diff <base>` with no second commit compares the base against the
|
|
29
|
+
# *working tree*, so it covers both.
|
|
30
|
+
# * `--changed=<base>` overrides it, for a CI system that knows better
|
|
31
|
+
# (a PR's target ref, say).
|
|
32
|
+
#
|
|
33
|
+
# **This still legitimately selects zero files**, and that is not a bug to
|
|
34
|
+
# be fixed by a cleverer base: on a default-branch build after a merge,
|
|
35
|
+
# `HEAD == origin/main`, so the merge base *is* HEAD and nothing differs.
|
|
36
|
+
# There is no diff base that makes "what changed on this build" non-empty
|
|
37
|
+
# there.
|
|
38
|
+
#
|
|
39
|
+
# That is why the load-bearing requirement is the **loud empty selection**,
|
|
40
|
+
# not the base. The caller must never be unable to tell "checked 12 files,
|
|
41
|
+
# found no annotations" from "checked 0 files" — {Selection} carries the
|
|
42
|
+
# count, the emptiness, and {Stats} explaining *which* filter emptied it, so
|
|
43
|
+
# 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
|
+
# *_spec.rb" stops looking. The exit code is not the lever: the spec fixes 0
|
|
46
|
+
# for "no annotations".
|
|
47
|
+
#
|
|
48
|
+
# == Scope: `--changed` selects changed specs **under `root`**
|
|
49
|
+
#
|
|
50
|
+
# The second explicit decision. `git diff --name-only` emits paths relative
|
|
51
|
+
# to the **repository root**, not to the process's working directory, so the
|
|
52
|
+
# two are only the same when you happen to stand at the top level. Selecting
|
|
53
|
+
# by raw git output would make `--changed` repo-scoped while the default
|
|
54
|
+
# mode is cwd-scoped (`Dir.glob` under `root`) — the same invocation would
|
|
55
|
+
# mean different things in the two modes.
|
|
56
|
+
#
|
|
57
|
+
# `--changed` is therefore **cwd-scoped, to match the default mode**: git's
|
|
58
|
+
# repo-relative paths are resolved against `git rev-parse --show-toplevel`,
|
|
59
|
+
# anything outside `root` is dropped, and what survives is returned relative
|
|
60
|
+
# to `root` — exactly the shape {select_all} returns. Running from
|
|
61
|
+
# `<repo>/sub` selects the changed specs under `sub`, and *counts the ones
|
|
62
|
+
# it dropped* (`stats.outside_root`) so an empty selection can say "3
|
|
63
|
+
# changed spec files, all outside this directory" instead of the falsehood
|
|
64
|
+
# "nothing in the diff matched".
|
|
65
|
+
#
|
|
66
|
+
# Paths come back from `git diff -z`: NUL-separated, and therefore never
|
|
67
|
+
# quoted. Without `-z`, `core.quotePath` (on by default) renders
|
|
68
|
+
# `spec/café_spec.rb` as the literal characters `"spec/caf\303\251_spec.rb"`,
|
|
69
|
+
# quotes and all, which no longer names a file — an accented spec file would
|
|
70
|
+
# be silently dropped and then misreported as "nothing changed".
|
|
71
|
+
#
|
|
72
|
+
# Known limitation: `git diff` cannot see **untracked** files, so a brand
|
|
73
|
+
# new spec file that has not been `git add`ed is not selected. In CI (a
|
|
74
|
+
# clean checkout of a commit) that cannot arise; locally the loud empty
|
|
75
|
+
# selection is what surfaces it.
|
|
76
|
+
module FileSelector
|
|
77
|
+
DEFAULT_GLOB = "**/*_spec.rb"
|
|
78
|
+
|
|
79
|
+
# Ordered probes for the default branch when no explicit base is given.
|
|
80
|
+
DEFAULT_BRANCH_REFS = %w[origin/HEAD origin/main origin/master main master].freeze
|
|
81
|
+
|
|
82
|
+
# Why an empty `--changed` selection is empty. Every count is a filter
|
|
83
|
+
# this class applied, in the order it applied them, so the CLI can name
|
|
84
|
+
# the real cause instead of guessing at the last one.
|
|
85
|
+
Stats = Data.define(:changed, :spec_matches, :outside_root, :unreadable) do
|
|
86
|
+
def initialize(changed: 0, spec_matches: 0, outside_root: 0, unreadable: 0)
|
|
87
|
+
super
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# What a selection produced, plus enough context to report it honestly.
|
|
92
|
+
Selection = Data.define(:files, :mode, :base, :note, :stats) do
|
|
93
|
+
def initialize(files:, mode:, base: nil, note: nil, stats: nil)
|
|
94
|
+
super
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def empty?
|
|
98
|
+
files.empty?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def count
|
|
102
|
+
files.length
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
module_function
|
|
107
|
+
|
|
108
|
+
# @param changed [Boolean] restrict to files in the diff
|
|
109
|
+
# @param base [String, nil] explicit diff base; nil means "work it out"
|
|
110
|
+
# @param root [String] directory to select within
|
|
111
|
+
# @return [Selection]
|
|
112
|
+
# @raise [UsageError] when `--changed` is used outside a git repository.
|
|
113
|
+
# Typed rather than a crash or a silent empty set: the exit-code
|
|
114
|
+
# contract makes this a misuse (2), and the CLI maps it later.
|
|
115
|
+
def select(changed: false, base: nil, root: Dir.pwd)
|
|
116
|
+
changed ? select_changed(base: base, root: root) : select_all(root: root)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Every `*_spec.rb` under `root`, recursively. Hidden directories are not
|
|
120
|
+
# traversed (no `File::FNM_DOTMATCH`), so `.git` and friends are skipped.
|
|
121
|
+
def select_all(root: Dir.pwd)
|
|
122
|
+
files = Dir.glob(DEFAULT_GLOB, base: root).select { |f| File.file?(File.join(root, f)) }.sort
|
|
123
|
+
Selection.new(files: files, mode: :all)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def select_changed(base: nil, root: Dir.pwd)
|
|
127
|
+
unless git_repository?(root)
|
|
128
|
+
raise UsageError, "--changed requires a git repository; #{root} is not inside one"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
resolved, base_kind = base ? [base, :explicit] : default_base(root)
|
|
132
|
+
unless resolved
|
|
133
|
+
raise UsageError,
|
|
134
|
+
"--changed could not determine a diff base (no #{DEFAULT_BRANCH_REFS.join(', ')} " \
|
|
135
|
+
"and no HEAD commit); pass --changed=<base> explicitly"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
files, stats = changed_files(resolved, root)
|
|
139
|
+
|
|
140
|
+
Selection.new(files: files, mode: :changed, base: resolved,
|
|
141
|
+
note: base_note(resolved, base_kind, root), stats: stats)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Resolves git's repo-root-relative output into paths relative to `root`,
|
|
145
|
+
# dropping (and counting) everything the two scoping rules exclude.
|
|
146
|
+
# @return [[Array<String>, Stats]]
|
|
147
|
+
def changed_files(base, root)
|
|
148
|
+
names = diff_names(base, root)
|
|
149
|
+
specs = names.select { |name| File.fnmatch?("*_spec.rb", name) }
|
|
150
|
+
|
|
151
|
+
top = toplevel(root)
|
|
152
|
+
prefix = directory_prefix(top.empty? ? root : top)
|
|
153
|
+
root_prefix = directory_prefix(real_path(root))
|
|
154
|
+
|
|
155
|
+
files = []
|
|
156
|
+
outside = 0
|
|
157
|
+
unreadable = 0
|
|
158
|
+
specs.each do |name|
|
|
159
|
+
absolute = prefix + name
|
|
160
|
+
relative = strip_prefix(absolute, root_prefix)
|
|
161
|
+
if relative.nil?
|
|
162
|
+
outside += 1
|
|
163
|
+
elsif !File.file?(absolute)
|
|
164
|
+
unreadable += 1
|
|
165
|
+
else
|
|
166
|
+
files << relative
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
[files.sort,
|
|
171
|
+
Stats.new(changed: names.length, spec_matches: specs.length,
|
|
172
|
+
outside_root: outside, unreadable: unreadable)]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# `--diff-filter=d` drops deleted paths — `git diff --name-only` lists
|
|
176
|
+
# them, and they then fail to open. `-z` makes the output machine-readable:
|
|
177
|
+
# NUL-separated and never `core.quotePath`-quoted, so a non-ASCII path
|
|
178
|
+
# survives intact and a path containing a newline cannot split a record.
|
|
179
|
+
def diff_names(base, root)
|
|
180
|
+
out, ok = git(%W[diff -z --name-only --diff-filter=d #{base} --], root)
|
|
181
|
+
raise UsageError, "--changed could not diff against #{base.inspect}" unless ok
|
|
182
|
+
|
|
183
|
+
out.split("\0").reject(&:empty?)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The repository's top level — what `git diff`'s paths are relative to.
|
|
187
|
+
# Empty when git cannot say, in which case the caller falls back to `root`
|
|
188
|
+
# (the top level *is* root for the common case of running from there).
|
|
189
|
+
def toplevel(root)
|
|
190
|
+
out, ok = git(%w[rev-parse --show-toplevel], root)
|
|
191
|
+
ok ? real_path(out.strip) : ""
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# The merge base of HEAD with the default branch.
|
|
195
|
+
# @return [[String, Symbol], nil] the base and how it was arrived at
|
|
196
|
+
# (`:merge_base`, or `:head_fallback` when no default-branch ref exists),
|
|
197
|
+
# or nil when there is no HEAD at all (a repository with no commits).
|
|
198
|
+
def default_base(root)
|
|
199
|
+
head, ok = git(%w[rev-parse --verify --quiet HEAD], root)
|
|
200
|
+
return nil unless ok && !head.strip.empty?
|
|
201
|
+
|
|
202
|
+
DEFAULT_BRANCH_REFS.each do |ref|
|
|
203
|
+
resolved, found = git(%W[rev-parse --verify --quiet #{ref}], root)
|
|
204
|
+
next unless found && !resolved.strip.empty?
|
|
205
|
+
|
|
206
|
+
merge_base, ok = git(%W[merge-base HEAD #{ref}], root)
|
|
207
|
+
return [merge_base.strip, :merge_base] if ok && !merge_base.strip.empty?
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Detached from any known default branch: fall back to HEAD, which
|
|
211
|
+
# selects uncommitted work only. Better than selecting everything.
|
|
212
|
+
[head.strip, :head_fallback]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Explains a base that can only produce a thin selection, and — the point
|
|
216
|
+
# of `base_kind` — distinguishes the two ways that happens. Both leave the
|
|
217
|
+
# base at HEAD, but "this is a default-branch build" is normal and "no
|
|
218
|
+
# default branch could be found" means `--changed` has quietly degraded to
|
|
219
|
+
# `git diff HEAD`, i.e. the working-tree-vs-HEAD no-op this class exists
|
|
220
|
+
# to avoid. Reporting the first when the second is true would be a
|
|
221
|
+
# confidently wrong explanation.
|
|
222
|
+
def base_note(resolved, base_kind, root)
|
|
223
|
+
case base_kind
|
|
224
|
+
when :head_fallback
|
|
225
|
+
"no default-branch ref (#{DEFAULT_BRANCH_REFS.join(', ')}) could be found, so the diff base " \
|
|
226
|
+
"fell back to HEAD; --changed can only select uncommitted changes here"
|
|
227
|
+
when :merge_base
|
|
228
|
+
head, ok = git(%w[rev-parse HEAD], root)
|
|
229
|
+
return nil unless ok && head.strip == resolved
|
|
230
|
+
|
|
231
|
+
"the diff base is HEAD itself (this looks like a default-branch build), " \
|
|
232
|
+
"so only uncommitted changes can be selected"
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def git_repository?(root)
|
|
237
|
+
out, ok = git(%w[rev-parse --is-inside-work-tree], root)
|
|
238
|
+
ok && out.strip == "true"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Runs git without a shell and without inheriting stderr into our output.
|
|
242
|
+
# @return [[String, Boolean]] stdout and whether git exited 0
|
|
243
|
+
def git(args, root)
|
|
244
|
+
out, _err, status = Open3.capture3("git", *args, chdir: root)
|
|
245
|
+
[out, status.success?]
|
|
246
|
+
rescue SystemCallError
|
|
247
|
+
# git not installed / not executable.
|
|
248
|
+
["", false]
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Symlink-resolved, so a `root` reached through a symlink still compares
|
|
252
|
+
# equal to the physical path git reports.
|
|
253
|
+
def real_path(path)
|
|
254
|
+
File.realpath(path)
|
|
255
|
+
rescue SystemCallError
|
|
256
|
+
File.expand_path(path)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def directory_prefix(path)
|
|
260
|
+
path.end_with?(File::SEPARATOR) ? path : path + File::SEPARATOR
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Byte-wise, so a path git returned that is not valid in the prefix's
|
|
264
|
+
# encoding cannot raise Encoding::CompatibilityError mid-selection. The
|
|
265
|
+
# result is handed back in the path's own encoding.
|
|
266
|
+
# @return [String, nil] `absolute` relative to `prefix`, or nil if outside
|
|
267
|
+
def strip_prefix(absolute, prefix)
|
|
268
|
+
bytes = absolute.b
|
|
269
|
+
head = prefix.b
|
|
270
|
+
return nil unless bytes.start_with?(head)
|
|
271
|
+
|
|
272
|
+
bytes[head.bytesize..].force_encoding(absolute.encoding)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecGuard
|
|
4
|
+
module RSpec
|
|
5
|
+
# One `@intent:` annotation, located and (where possible) parsed.
|
|
6
|
+
#
|
|
7
|
+
# Exactly one of `intent` and `problem` is non-nil:
|
|
8
|
+
#
|
|
9
|
+
# * `intent` — the parsed annotation as a Hash with String keys. Says
|
|
10
|
+
# nothing about whether it is *valid*; schema validation is a later
|
|
11
|
+
# stage, so a Finding with an `intent` may still be rejected then.
|
|
12
|
+
# * `problem` — why this annotation could not be turned into a Hash at
|
|
13
|
+
# all, as a human-readable sentence.
|
|
14
|
+
#
|
|
15
|
+
# `kind` names *how* it failed and is nil when it did not. It is carried
|
|
16
|
+
# here rather than reconstructed by the caller because a failed extraction
|
|
17
|
+
# and an unparseable payload both land in `problem`, which makes the two
|
|
18
|
+
# indistinguishable downstream once flattened to prose.
|
|
19
|
+
#
|
|
20
|
+
# `line` is the 1-based line the annotation sits on — except under
|
|
21
|
+
# {KIND_READ}, where nothing in the file was ever seen and
|
|
22
|
+
# {Scanner.scan_file} / {Scanner.scan_text} construct the Finding with a
|
|
23
|
+
# `line` of 0. That 0 is a sentinel standing in for "no line", not a
|
|
24
|
+
# position anything can be pointed at.
|
|
25
|
+
#
|
|
26
|
+
# NOTE: this is a subclass of an anonymous `Data.define` rather than a
|
|
27
|
+
# `Data.define do ... end` block, because a constant assigned inside that
|
|
28
|
+
# block binds to the *lexically* enclosing module (SpecGuard::RSpec) rather
|
|
29
|
+
# than to the value class — so KIND_EXTRACTION below would silently not be
|
|
30
|
+
# `Finding::KIND_EXTRACTION`.
|
|
31
|
+
class Finding < Data.define(:file, :line, :intent, :problem, :kind)
|
|
32
|
+
# The annotation's payload could not be captured off the line at all
|
|
33
|
+
# (unterminated literal, or no `{...}` after the token).
|
|
34
|
+
KIND_EXTRACTION = :extraction
|
|
35
|
+
|
|
36
|
+
# The payload was captured but is not JSON even after normalization.
|
|
37
|
+
KIND_PARSE = :parse
|
|
38
|
+
|
|
39
|
+
# The file itself could not be read (missing, unopenable, or not valid
|
|
40
|
+
# UTF-8), so no annotation in it was ever seen. Distinct from
|
|
41
|
+
# {KIND_EXTRACTION} — nothing here is a claim about an annotation — and
|
|
42
|
+
# named after the `read` kind the validator's `--json` report carries, so
|
|
43
|
+
# the two classifications line up.
|
|
44
|
+
KIND_READ = :read
|
|
45
|
+
|
|
46
|
+
# The payload parsed but violates the OpenTestIntent schema. Not produced
|
|
47
|
+
# by discovery — {Linter} stamps it — but it lives here so the full set
|
|
48
|
+
# of ways an annotation can fail is written down in one place.
|
|
49
|
+
KIND_SCHEMA = :schema
|
|
50
|
+
|
|
51
|
+
def initialize(file:, line:, intent: nil, problem: nil, kind: nil)
|
|
52
|
+
super
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# True when the annotation yielded a Hash. Not a claim that it is *valid*.
|
|
56
|
+
def extracted?
|
|
57
|
+
problem.nil?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def problem?
|
|
61
|
+
!problem.nil?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Deliberately no `#location` here. Rendering `file:line` off a Finding
|
|
65
|
+
# prints `file:0` for the {KIND_READ} sentinel above, and `:0` is not
|
|
66
|
+
# somewhere a reader can go — anything parsing `file:line` would be sent
|
|
67
|
+
# to a line that does not exist. {Linter::Result#line_scoped?} is where
|
|
68
|
+
# that rule lives, and both renderers that obey it ({CLI#report_failure}
|
|
69
|
+
# and {JSONReporter}) are sited on that object so neither can drift from
|
|
70
|
+
# it. A copy of the predicate on this one is the third spelling that
|
|
71
|
+
# comment warns against, so render a Finding through {Linter::Result}.
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|