specguard-ruby 0.3.9 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6266c2bcd8775838ab93bbb3963cb007465575ee038f67490161fa64e10e982e
4
- data.tar.gz: 7799e565338f59d3a76b4b19e19a0877dd172072e772e8cc81909f5d040d3ec7
3
+ metadata.gz: c7d1dfb3eb7af542eee8c1202dae06dd0d9c3981f5415d12875037c9a9067944
4
+ data.tar.gz: 9d785e02b70ea9e06b0d0cf55ce31cbb1d67c841ea36fb0b7f3c7217a8f20304
5
5
  SHA512:
6
- metadata.gz: 3e811640885a89c54dec7bd69702ca429b0f28f5bfdd7c76fd049d15621da4ce0b48323af914448236877f9bc40df78df642232cda89dbfef5a0adcd85612ed7
7
- data.tar.gz: 28e11252d3051943734247a9878a50680ee719cff789a1ea53da11041574529ed5d9e3761c5445f33b84b0ae42994ae121d35895ec6c0f33fd2c8045163c329e
6
+ metadata.gz: 3d5ae9e309ae02ea33679223f712d04edf7c9e06aab22f011fde61484f2f3c8d2c992b02b792caceeb934b63136734840cdd01d0dbe62619b695dd09c3ceb238
7
+ data.tar.gz: 3d3f373483219a5cdbeecdf639065bd99cb90dd648acf4bd75174ae9eba6adefcfc94a6458577480bb526dc636d88b139a4742cda3c12269a320bd5e7f994a3f
data/README.md CHANGED
@@ -58,7 +58,16 @@ bundle exec specguard-lint # one-off audit: every *_spec.rb
58
58
  the default branch** (`origin/HEAD`, then `origin/main`/`origin/master`, then
59
59
  their local names) — never a bare working-tree-vs-index `git diff`, which is
60
60
  empty on a clean checkout. `--changed=<base>` overrides the base for pipelines
61
- that know better. In a **shallow** checkout the default depth-1 `git clone`
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`
62
71
  behind `actions/checkout@v4` — the merge base with the default branch is not
63
72
  in the clone's history, so the derived base is HEAD itself and the selection
64
73
  comes up empty: it still exits `0`, but its stderr note (and `--json`
@@ -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
 
@@ -87,10 +87,32 @@ module SpecGuard
87
87
  # quotes and all, which no longer names a file — an accented spec file would
88
88
  # be silently dropped and then misreported as "nothing changed".
89
89
  #
90
- # Known limitation: `git diff` cannot see **untracked** files, so a brand
91
- # new spec file that has not been `git add`ed is not selected. In CI (a
92
- # clean checkout of a commit) that cannot arise; locally the loud empty
93
- # selection is what surfaces it.
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.
94
116
  module FileSelector
95
117
  # `test/**/*_test.rb` rather than `**/*_test.rb`: the `test/` directory
96
118
  # is where both Rails and Minitest itself put Minitest files, and
@@ -112,9 +134,13 @@ module SpecGuard
112
134
 
113
135
  # Why an empty `--changed` selection is empty. Every count is a filter
114
136
  # this class applied, in the order it applied them, so the CLI can name
115
- # the real cause instead of guessing at the last one.
116
- Stats = Data.define(:changed, :spec_matches, :outside_root, :unreadable) do
117
- def initialize(changed: 0, spec_matches: 0, outside_root: 0, unreadable: 0)
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)
118
144
  super
119
145
  end
120
146
  end
@@ -178,20 +204,31 @@ module SpecGuard
178
204
  end
179
205
 
180
206
  # Resolves git's repo-root-relative output into paths relative to `root`,
181
- # 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.
182
213
  # @return [[Array<String>, Stats]]
183
214
  def changed_files(base, root, is_shallow)
184
- names = diff_names(base, root, is_shallow)
185
- specs = names.select { |name| CHANGED_PATTERNS.any? { |pattern| File.fnmatch?(pattern, name) } }
215
+ names = diff_names(base, root, is_shallow).map { |name| [name, false] }
186
216
 
187
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
+
188
224
  prefix = directory_prefix(top.empty? ? root : top)
189
225
  root_prefix = directory_prefix(real_path(root))
190
226
 
191
227
  files = []
192
228
  outside = 0
193
229
  unreadable = 0
194
- specs.each do |name|
230
+ untracked = 0
231
+ specs.each do |(name, was_untracked)|
195
232
  absolute = prefix + name
196
233
  relative = strip_prefix(absolute, root_prefix)
197
234
  if relative.nil?
@@ -200,12 +237,13 @@ module SpecGuard
200
237
  unreadable += 1
201
238
  else
202
239
  files << relative
240
+ untracked += 1 if was_untracked
203
241
  end
204
242
  end
205
243
 
206
244
  [files.sort,
207
245
  Stats.new(changed: names.length, spec_matches: specs.length,
208
- outside_root: outside, unreadable: unreadable)]
246
+ outside_root: outside, unreadable: unreadable, untracked: untracked)]
209
247
  end
210
248
 
211
249
  # `--diff-filter=d` drops deleted paths — `git diff --name-only` lists
@@ -233,6 +271,26 @@ module SpecGuard
233
271
  out.split("\0").reject(&:empty?)
234
272
  end
235
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
+
236
294
  # The repository's top level — what `git diff`'s paths are relative to.
237
295
  # Empty when git cannot say, in which case the caller falls back to `root`
238
296
  # (the top level *is* root for the common case of running from there).
@@ -308,9 +366,12 @@ module SpecGuard
308
366
  # Memoized per-run `git rev-parse --is-shallow-repository` probe.
309
367
  # SPGD-1035: shallowness is consulted only in the branches where it
310
368
  # changes the message (a derived base of HEAD, a failed diff), so the
311
- # full-clone happy path makes zero new git invocations and the memo caps
312
- # the probe at one per run. An unreadable answer (old git without the
313
- # flag) reads as not shallow, keeping today's messages exactly.
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.
314
375
  # @return [Proc] zero-argument; true iff git reports a shallow repository
315
376
  def shallow_probe(root)
316
377
  cached = nil
@@ -7,5 +7,5 @@
7
7
  # framework-scoped version constant a lie, so the number moved here and the
8
8
  # rspec module now points at it for back-compat.
9
9
  module SpecGuard
10
- VERSION = "0.3.9"
10
+ VERSION = "0.3.10"
11
11
  end
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.9
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 00:00:00.000000000 Z
11
+ date: 2026-09-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json