specguard-ruby 0.3.8 → 0.3.10
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 +25 -0
- data/lib/specguard/rspec/cli.rb +10 -1
- data/lib/specguard/rspec/file_selector.rb +147 -18
- 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: c7d1dfb3eb7af542eee8c1202dae06dd0d9c3981f5415d12875037c9a9067944
|
|
4
|
+
data.tar.gz: 9d785e02b70ea9e06b0d0cf55ce31cbb1d67c841ea36fb0b7f3c7217a8f20304
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3d5ae9e309ae02ea33679223f712d04edf7c9e06aab22f011fde61484f2f3c8d2c992b02b792caceeb934b63136734840cdd01d0dbe62619b695dd09c3ceb238
|
|
7
|
+
data.tar.gz: 3d3f373483219a5cdbeecdf639065bd99cb90dd648acf4bd75174ae9eba6adefcfc94a6458577480bb526dc636d88b139a4742cda3c12269a320bd5e7f994a3f
|
data/README.md
CHANGED
|
@@ -54,6 +54,31 @@ bundle exec specguard-lint --changed # CI mode: only files in the current diff
|
|
|
54
54
|
bundle exec specguard-lint # one-off audit: every *_spec.rb
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
`--changed` is the CI selection mode: it diffs against the **merge base with
|
|
58
|
+
the default branch** (`origin/HEAD`, then `origin/main`/`origin/master`, then
|
|
59
|
+
their local names) — never a bare working-tree-vs-index `git diff`, which is
|
|
60
|
+
empty on a clean checkout. `--changed=<base>` overrides the base for pipelines
|
|
61
|
+
that know better. Selection also takes in **untracked** files: a brand-new
|
|
62
|
+
spec that has not been `git add`ed is part of what the branch changed, whether
|
|
63
|
+
or not the change is committed yet. One `git ls-files --others
|
|
64
|
+
--exclude-standard` call per run is unioned with the diff — `.gitignore`d
|
|
65
|
+
paths (scratch directories, vendored code, build output) never enter the
|
|
66
|
+
selection — and untracked files obey the same scoping as diffed ones, so an
|
|
67
|
+
untracked spec outside the current directory is counted as outside, not
|
|
68
|
+
checked. When the untracked leg contributed, the `checked N spec files changed
|
|
69
|
+
since <base>` line says `including M untracked`.
|
|
70
|
+
In a **shallow** checkout — the default depth-1 `git clone`
|
|
71
|
+
behind `actions/checkout@v4` — the merge base with the default branch is not
|
|
72
|
+
in the clone's history, so the derived base is HEAD itself and the selection
|
|
73
|
+
comes up empty: it still exits `0`, but its stderr note (and `--json`
|
|
74
|
+
`selection.note`) names the checkout as **shallow** and the remedy — fetch the
|
|
75
|
+
default branch (`fetch-depth: 0`) or pass `--changed=<base>` naming a base the
|
|
76
|
+
checkout contains. An explicit `--changed=<base>` that is not in a shallow
|
|
77
|
+
checkout's history is a usage error (exit `2`) naming that cause and the same
|
|
78
|
+
remedy, not the bare "could not diff against" a full clone reports for a
|
|
79
|
+
genuinely bad ref. A selection that comes up empty stays exit `0` and says WHY
|
|
80
|
+
on stderr — the exit contract is unchanged; only the WHY is truthful now.
|
|
81
|
+
|
|
57
82
|
Files are **positional** (`specguard-lint spec/order_spec.rb`); there is no `--source` flag —
|
|
58
83
|
that belongs to `validate-intent`, not to this one.
|
|
59
84
|
|
data/lib/specguard/rspec/cli.rb
CHANGED
|
@@ -228,14 +228,23 @@ module SpecGuard
|
|
|
228
228
|
# `summary.files`. The warnings are diagnostics about the linter rather
|
|
229
229
|
# than findings, so they stay on stderr exactly as they are — a run that
|
|
230
230
|
# selected nothing is still loud, in both renderers.
|
|
231
|
+
#
|
|
232
|
+
# The `--changed` count names its provenance: files can reach the
|
|
233
|
+
# selection through the untracked leg (`file_selector.rb`), and "changed
|
|
234
|
+
# since <base>" alone over-claims for a file the diff never saw. When
|
|
235
|
+
# the untracked leg contributed, the line says how many — the same
|
|
236
|
+
# `stats.untracked` the selection carries, so the clause can only ever
|
|
237
|
+
# name files this run actually checked.
|
|
231
238
|
def report_selection(selection, json:)
|
|
232
239
|
if selection.empty?
|
|
233
240
|
@stderr.puts "specguard-lint: warning: selected 0 spec files — #{empty_reason(selection)}"
|
|
234
241
|
@stderr.puts "specguard-lint: warning: #{selection.note}" if selection.note
|
|
235
242
|
elsif !json
|
|
243
|
+
untracked = selection.mode == :changed ? selection.stats&.untracked.to_i : 0
|
|
236
244
|
@stdout.puts "specguard-lint: checked #{selection.count} spec file#{'s' unless selection.count == 1}" \
|
|
237
245
|
"#{" changed since #{selection.base}" if selection.mode == :changed}" \
|
|
238
|
-
"#{" under #{Dir.pwd}" if selection.mode == :all}"
|
|
246
|
+
"#{" under #{Dir.pwd}" if selection.mode == :all}" \
|
|
247
|
+
"#{" including #{untracked} untracked" if untracked.positive?}"
|
|
239
248
|
end
|
|
240
249
|
end
|
|
241
250
|
|
|
@@ -43,6 +43,16 @@ module SpecGuard
|
|
|
43
43
|
# There is no diff base that makes "what changed on this build" non-empty
|
|
44
44
|
# there.
|
|
45
45
|
#
|
|
46
|
+
# A **shallow** checkout — the depth-1 clone CI defaults to
|
|
47
|
+
# (`actions/checkout@v4`) — reaches the same thin base by a third road the
|
|
48
|
+
# ref-resolution above cannot see: every `DEFAULT_BRANCH_REFS` probe
|
|
49
|
+
# *resolves* (the fetched tip is in the clone), but the history behind it
|
|
50
|
+
# is not, so the merge base with the default branch is outside the clone
|
|
51
|
+
# and `merge-base` collapses onto HEAD. The notes below therefore consult
|
|
52
|
+
# `git rev-parse --is-shallow-repository` — lazily and memoized, only
|
|
53
|
+
# where the answer changes the message — and in a shallow clone say that,
|
|
54
|
+
# never "default-branch build".
|
|
55
|
+
#
|
|
46
56
|
# That is why the load-bearing requirement is the **loud empty selection**,
|
|
47
57
|
# not the base. The caller must never be unable to tell "checked 12 files,
|
|
48
58
|
# found no annotations" from "checked 0 files" — {Selection} carries the
|
|
@@ -77,10 +87,32 @@ module SpecGuard
|
|
|
77
87
|
# quotes and all, which no longer names a file — an accented spec file would
|
|
78
88
|
# be silently dropped and then misreported as "nothing changed".
|
|
79
89
|
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
#
|
|
90
|
+
# == Untracked files are selected too
|
|
91
|
+
#
|
|
92
|
+
# `git diff` cannot see a file that has never been `git add`ed — but "what
|
|
93
|
+
# this branch changed, whether or not the change is committed yet" covers a
|
|
94
|
+
# never-added file, and a mode that silently skipped it would ride the
|
|
95
|
+
# branch's newest spec past the gate: in the mixed shape every real working
|
|
96
|
+
# tree has (a tracked edit somewhere, the new spec still untracked), the
|
|
97
|
+
# selection was non-empty without the new file, so the loud-empty machinery
|
|
98
|
+
# never fired and the defect shipped behind a checked-count reporting
|
|
99
|
+
# success.
|
|
100
|
+
#
|
|
101
|
+
# So the `--changed` name set is a union: the diff's paths plus ONE
|
|
102
|
+
# `git ls-files --others --exclude-standard -z` call per selection, run at
|
|
103
|
+
# the toplevel so its repo-root-relative output flows through the same
|
|
104
|
+
# scoping as the diff's (from a subdirectory `ls-files` emits
|
|
105
|
+
# cwd-relative paths, which would defeat it). `--exclude-standard` is the
|
|
106
|
+
# boundary: `.gitignore`d paths — scratch directories, vendored code, build
|
|
107
|
+
# output — never enter the selection. The two legs are disjoint by
|
|
108
|
+
# construction (an untracked path is never a diff path), so the union
|
|
109
|
+
# cannot double-count; {Stats.untracked} says how many of the selected
|
|
110
|
+
# files arrived via the untracked leg, and the empty-reason ladder's
|
|
111
|
+
# "nothing changed against <base>" can now only fire when the working tree
|
|
112
|
+
# is genuinely clean. That `ls-files` call is the untracked leg's entire
|
|
113
|
+
# added cost, and it is scoped to this mode — the "zero new git
|
|
114
|
+
# invocations on the full-clone happy path" property SPGD-1035 established
|
|
115
|
+
# belongs to the lazy shallow probe alone, not to `--changed` as a whole.
|
|
84
116
|
module FileSelector
|
|
85
117
|
# `test/**/*_test.rb` rather than `**/*_test.rb`: the `test/` directory
|
|
86
118
|
# is where both Rails and Minitest itself put Minitest files, and
|
|
@@ -102,9 +134,13 @@ module SpecGuard
|
|
|
102
134
|
|
|
103
135
|
# Why an empty `--changed` selection is empty. Every count is a filter
|
|
104
136
|
# this class applied, in the order it applied them, so the CLI can name
|
|
105
|
-
# the real cause instead of guessing at the last one.
|
|
106
|
-
|
|
107
|
-
|
|
137
|
+
# the real cause instead of guessing at the last one. The exception is
|
|
138
|
+
# `untracked`, which filters nothing: it counts the selected files that
|
|
139
|
+
# arrived via the untracked leg, so the report can say where a file the
|
|
140
|
+
# diff never saw came from (an untracked spec outside `root` is counted
|
|
141
|
+
# in `outside_root`, not here — this names files the run checked).
|
|
142
|
+
Stats = Data.define(:changed, :spec_matches, :outside_root, :unreadable, :untracked) do
|
|
143
|
+
def initialize(changed: 0, spec_matches: 0, outside_root: 0, unreadable: 0, untracked: 0)
|
|
108
144
|
super
|
|
109
145
|
end
|
|
110
146
|
end
|
|
@@ -157,27 +193,42 @@ module SpecGuard
|
|
|
157
193
|
"and no HEAD commit); pass --changed=<base> explicitly"
|
|
158
194
|
end
|
|
159
195
|
|
|
160
|
-
|
|
196
|
+
# One memoized probe per run, shared by the two message branches that
|
|
197
|
+
# consult it (a failed diff, a thin base note).
|
|
198
|
+
is_shallow = shallow_probe(root)
|
|
199
|
+
|
|
200
|
+
files, stats = changed_files(resolved, root, is_shallow)
|
|
161
201
|
|
|
162
202
|
Selection.new(files: files, mode: :changed, base: resolved,
|
|
163
|
-
note: base_note(resolved, base_kind, root), stats: stats)
|
|
203
|
+
note: base_note(resolved, base_kind, root, is_shallow), stats: stats)
|
|
164
204
|
end
|
|
165
205
|
|
|
166
206
|
# Resolves git's repo-root-relative output into paths relative to `root`,
|
|
167
|
-
# dropping (and counting) everything the two scoping rules exclude.
|
|
207
|
+
# dropping (and counting) everything the two scoping rules exclude. The
|
|
208
|
+
# name set is the union of the diff leg and the untracked leg; each name
|
|
209
|
+
# is tagged with its leg so {Stats.untracked} can attribute the selected
|
|
210
|
+
# files the diff never saw. The legs are disjoint by construction (an
|
|
211
|
+
# untracked path is never a diff path), so the plain union cannot
|
|
212
|
+
# double-count and needs no dedup.
|
|
168
213
|
# @return [[Array<String>, Stats]]
|
|
169
|
-
def changed_files(base, root)
|
|
170
|
-
names = diff_names(base, root)
|
|
171
|
-
specs = names.select { |name| CHANGED_PATTERNS.any? { |pattern| File.fnmatch?(pattern, name) } }
|
|
214
|
+
def changed_files(base, root, is_shallow)
|
|
215
|
+
names = diff_names(base, root, is_shallow).map { |name| [name, false] }
|
|
172
216
|
|
|
173
217
|
top = toplevel(root)
|
|
218
|
+
names += untracked_names(top.empty? ? root : top).map { |name| [name, true] }
|
|
219
|
+
|
|
220
|
+
specs = names.select { |(name, _)|
|
|
221
|
+
CHANGED_PATTERNS.any? { |pattern| File.fnmatch?(pattern, name) }
|
|
222
|
+
}
|
|
223
|
+
|
|
174
224
|
prefix = directory_prefix(top.empty? ? root : top)
|
|
175
225
|
root_prefix = directory_prefix(real_path(root))
|
|
176
226
|
|
|
177
227
|
files = []
|
|
178
228
|
outside = 0
|
|
179
229
|
unreadable = 0
|
|
180
|
-
|
|
230
|
+
untracked = 0
|
|
231
|
+
specs.each do |(name, was_untracked)|
|
|
181
232
|
absolute = prefix + name
|
|
182
233
|
relative = strip_prefix(absolute, root_prefix)
|
|
183
234
|
if relative.nil?
|
|
@@ -186,25 +237,60 @@ module SpecGuard
|
|
|
186
237
|
unreadable += 1
|
|
187
238
|
else
|
|
188
239
|
files << relative
|
|
240
|
+
untracked += 1 if was_untracked
|
|
189
241
|
end
|
|
190
242
|
end
|
|
191
243
|
|
|
192
244
|
[files.sort,
|
|
193
245
|
Stats.new(changed: names.length, spec_matches: specs.length,
|
|
194
|
-
outside_root: outside, unreadable: unreadable)]
|
|
246
|
+
outside_root: outside, unreadable: unreadable, untracked: untracked)]
|
|
195
247
|
end
|
|
196
248
|
|
|
197
249
|
# `--diff-filter=d` drops deleted paths — `git diff --name-only` lists
|
|
198
250
|
# them, and they then fail to open. `-z` makes the output machine-readable:
|
|
199
251
|
# NUL-separated and never `core.quotePath`-quoted, so a non-ASCII path
|
|
200
252
|
# survives intact and a path containing a newline cannot split a record.
|
|
201
|
-
def diff_names(base, root)
|
|
253
|
+
def diff_names(base, root, is_shallow)
|
|
202
254
|
out, ok = git(%W[diff -z --name-only --diff-filter=d #{base} --], root)
|
|
203
|
-
|
|
255
|
+
unless ok
|
|
256
|
+
if is_shallow.call
|
|
257
|
+
# SPGD-1035: in a shallow checkout the overwhelmingly likely cause
|
|
258
|
+
# is that <base> is simply not in the clone's history (git's own
|
|
259
|
+
# stderr says `fatal: bad object` / `unknown revision`), and the
|
|
260
|
+
# fix is a fetch — a cause the bare message never named, leaving
|
|
261
|
+
# "bad ref" and "shallow history" indistinguishable. Non-shallow
|
|
262
|
+
# failures keep the original message.
|
|
263
|
+
raise UsageError,
|
|
264
|
+
"--changed could not diff against #{base.inspect}: this checkout is shallow and " \
|
|
265
|
+
"#{base.inspect} is not in its history — fetch it (fetch-depth: 0, or " \
|
|
266
|
+
"git fetch origin #{base}) or pass a base the checkout contains"
|
|
267
|
+
end
|
|
268
|
+
raise UsageError, "--changed could not diff against #{base.inspect}"
|
|
269
|
+
end
|
|
204
270
|
|
|
205
271
|
out.split("\0").reject(&:empty?)
|
|
206
272
|
end
|
|
207
273
|
|
|
274
|
+
# The untracked leg of `--changed`: every file git does not track, minus
|
|
275
|
+
# what `--exclude-standard` excludes (`.gitignore`, `.git/info/exclude`,
|
|
276
|
+
# the global excludes file). Run at the toplevel, where `ls-files` emits
|
|
277
|
+
# repo-root-relative paths — from a subdirectory it emits cwd-relative
|
|
278
|
+
# ones, which would break the root-scoping the diff leg's output goes
|
|
279
|
+
# through. `-z` for the same reason as {diff_names}: NUL-separated and
|
|
280
|
+
# never `core.quotePath`-quoted. Without `--directory` git lists the
|
|
281
|
+
# files inside an untracked directory rather than the directory itself,
|
|
282
|
+
# which is what the suffix filter wants. `ls-files` reads the working
|
|
283
|
+
# tree and the index only — no history — so the leg works in a shallow
|
|
284
|
+
# clone where the diff base barely exists. A failure degrades to an
|
|
285
|
+
# empty leg rather than failing the run: the repository was already
|
|
286
|
+
# verified, the tracked diff above remains the primary evidence, and a
|
|
287
|
+
# half-readable working tree must not kill a gate that would otherwise
|
|
288
|
+
# check every tracked spec.
|
|
289
|
+
def untracked_names(chdir)
|
|
290
|
+
out, ok = git(%w[ls-files --others --exclude-standard -z], chdir)
|
|
291
|
+
ok ? out.split("\0").reject(&:empty?) : []
|
|
292
|
+
end
|
|
293
|
+
|
|
208
294
|
# The repository's top level — what `git diff`'s paths are relative to.
|
|
209
295
|
# Empty when git cannot say, in which case the caller falls back to `root`
|
|
210
296
|
# (the top level *is* root for the common case of running from there).
|
|
@@ -241,20 +327,63 @@ module SpecGuard
|
|
|
241
327
|
# `git diff HEAD`, i.e. the working-tree-vs-HEAD no-op this class exists
|
|
242
328
|
# to avoid. Reporting the first when the second is true would be a
|
|
243
329
|
# confidently wrong explanation.
|
|
244
|
-
|
|
330
|
+
#
|
|
331
|
+
# SPGD-1035: a shallow (depth-limited) checkout reproduces both thin
|
|
332
|
+
# shapes with a third cause those two stories miss — the merge base with
|
|
333
|
+
# the default branch is not in the clone's history at all — so whenever
|
|
334
|
+
# the repo IS shallow the note tells that story and names the remedy
|
|
335
|
+
# (fetch the default branch, or pass a base the checkout contains),
|
|
336
|
+
# retiring the "default-branch build" guess for shallow repos in both
|
|
337
|
+
# shapes.
|
|
338
|
+
def base_note(resolved, base_kind, root, is_shallow)
|
|
339
|
+
shallow_remedy =
|
|
340
|
+
"fetch the default branch (fetch-depth: 0) or pass --changed=<base> naming a base this checkout contains"
|
|
245
341
|
case base_kind
|
|
246
342
|
when :head_fallback
|
|
343
|
+
if is_shallow.call
|
|
344
|
+
return "this checkout is a shallow (depth-limited) clone and no default-branch ref " \
|
|
345
|
+
"(#{DEFAULT_BRANCH_REFS.join(', ')}) is in its history, so the diff base fell " \
|
|
346
|
+
"back to HEAD; --changed can only select uncommitted changes here — #{shallow_remedy}"
|
|
347
|
+
end
|
|
348
|
+
|
|
247
349
|
"no default-branch ref (#{DEFAULT_BRANCH_REFS.join(', ')}) could be found, so the diff base " \
|
|
248
350
|
"fell back to HEAD; --changed can only select uncommitted changes here"
|
|
249
351
|
when :merge_base
|
|
250
352
|
head, ok = git(%w[rev-parse HEAD], root)
|
|
251
353
|
return nil unless ok && head.strip == resolved
|
|
252
354
|
|
|
355
|
+
if is_shallow.call
|
|
356
|
+
return "this checkout is a shallow (depth-limited) clone, so the merge base with the " \
|
|
357
|
+
"default branch is not in its history and the diff base is HEAD itself — only " \
|
|
358
|
+
"uncommitted changes can be selected; #{shallow_remedy}"
|
|
359
|
+
end
|
|
360
|
+
|
|
253
361
|
"the diff base is HEAD itself (this looks like a default-branch build), " \
|
|
254
362
|
"so only uncommitted changes can be selected"
|
|
255
363
|
end
|
|
256
364
|
end
|
|
257
365
|
|
|
366
|
+
# Memoized per-run `git rev-parse --is-shallow-repository` probe.
|
|
367
|
+
# SPGD-1035: shallowness is consulted only in the branches where it
|
|
368
|
+
# changes the message (a derived base of HEAD, a failed diff), so the
|
|
369
|
+
# probe makes zero new git invocations of its own on the full-clone
|
|
370
|
+
# happy path, and the memo caps it at one per run. (That zero is the
|
|
371
|
+
# probe's, not the mode's: the untracked leg adds its one `ls-files`
|
|
372
|
+
# call to every `--changed` selection, whatever the clone's depth.) An
|
|
373
|
+
# unreadable answer (old git without the flag) reads as not shallow,
|
|
374
|
+
# keeping today's messages exactly.
|
|
375
|
+
# @return [Proc] zero-argument; true iff git reports a shallow repository
|
|
376
|
+
def shallow_probe(root)
|
|
377
|
+
cached = nil
|
|
378
|
+
lambda do
|
|
379
|
+
if cached.nil?
|
|
380
|
+
out, ok = git(%w[rev-parse --is-shallow-repository], root)
|
|
381
|
+
cached = ok && out.strip == "true"
|
|
382
|
+
end
|
|
383
|
+
cached
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
258
387
|
def git_repository?(root)
|
|
259
388
|
out, ok = git(%w[rev-parse --is-inside-work-tree], root)
|
|
260
389
|
ok && out.strip == "true"
|
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.10
|
|
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-09-
|
|
11
|
+
date: 2026-09-14 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: json
|