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.
@@ -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