specs_for 0.2.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 49cbe4e021b76ac0fcf642d68e4047a591c1618b47e97360822d7a7fc8f691c0
4
+ data.tar.gz: e82d6767e86e83dc4e2b97a9de51121e497a64cf70744dad1e4ea4c256083391
5
+ SHA512:
6
+ metadata.gz: 54b8e0df1127883a36f8b87c5ec57aa60e86a290f08fbddb413cf7061fd00a8ebffc9f090a2600a440afadf957f2bc5b30f38ba865e59105bf0211ee83d266be
7
+ data.tar.gz: 4e1409fce1fceba6b3763cce5c824c0fc298bf64cf0cae6cd784f27800ea984982f2abd71119db95875a51e914639f5f28ff20939eb42cea92d2a30f94be76c0
data/CHANGELOG.md ADDED
@@ -0,0 +1,28 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.2.0] - 2025-05-06
9
+
10
+ ### Updates:
11
+
12
+ - improved command parser
13
+ - support -c --changed files
14
+ - add better/more CLI specs
15
+ - add AI context
16
+
17
+ ## [1.0.0] - 2026-03-25
18
+
19
+ ### Added:
20
+
21
+ - Initial release of the `specs-for` gem.
22
+ - `bin/specs-for` CLI executable.
23
+ - `SpecsFor::Finder` — maps source files to their spec file counterparts.
24
+ - `SpecsFor::Runner` — parses CLI options, discovers git-changed files when no
25
+ explicit arguments are given, and executes RSpec.
26
+ - RSpec test suite for `Finder` and `Runner`.
27
+ - GitHub Actions CI workflow (runs RSpec on every push/PR to `main`).
28
+ - GitHub Actions Release workflow (runs tests then publishes gem on version tag push).
data/CLAUDE.md ADDED
@@ -0,0 +1,52 @@
1
+ # CLAUDE.md
2
+
3
+ This file gives repository-specific guidance for working on `specs-for`.
4
+
5
+ ## Project Purpose
6
+
7
+ `specs-for` is a Ruby gem that selects and runs RSpec files for provided input paths.
8
+
9
+ Current behavior highlights:
10
+ - Accepts one or more explicit file/directory arguments.
11
+ - Supports `-` to read filenames from `STDIN`.
12
+ - Supports `-c/--changed` to use files from `git status -s`.
13
+ - Uses Git ignore rules when handling changed files (`git check-ignore`), so ignored files (for example `vendor/`) are not considered.
14
+ - Does not run `rspec` when there is no effective input.
15
+
16
+ ## Useful Commands
17
+
18
+ Setup:
19
+ - `bin/setup`
20
+
21
+ Run specs:
22
+ - `bundle exec rspec`
23
+ - `bundle exec rspec spec/specs_for_spec.rb`
24
+ - `bundle exec rake`
25
+
26
+ Run the CLI:
27
+ - `bin/specs-for lib/specs_for.rb`
28
+ - `git diff --name-only | bin/specs-for -`
29
+ - `bin/specs-for -c` # only run specs on changed files
30
+
31
+ ## Repository Layout
32
+
33
+ - `lib/specs_for.rb` - main CLI implementation
34
+ - `lib/specs_for/version.rb` - gem version
35
+ - `bin/specs-for` and `bin/specs_for` - executable entrypoints
36
+ - `spec/specs_for_spec.rb` - primary behavior specs
37
+ - `spec/spec_helper.rb` - RSpec configuration
38
+ - `specs_for.gemspec` - gemspec
39
+
40
+ ## Coding Conventions For This Repo
41
+
42
+ - Prefer small, focused changes and keep behavior backwards-compatible unless explicitly changing semantics.
43
+ - Add or update specs for any behavior change in `lib/specs_for.rb`.
44
+ - Keep CLI error paths explicit and testable (`SystemExit` with expected status).
45
+ - Prefer Git as the source of truth for ignore behavior rather than re-implementing `.gitignore` parsing.
46
+ - Keep output noise low in tests by stubbing `warn` when asserting error exits.
47
+
48
+ ## Notes For AI Assistants
49
+
50
+ - This is a gem project, not a Rails app; avoid Rails-specific conventions unless intentionally introduced.
51
+ - Respect existing command style (`bundle exec ...`, `bin/...` wrappers).
52
+ - Do not assume optional tooling (`ag`, `pry`) is available; code already handles fallbacks.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Alan Stebbens
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # SpecsFor
2
+
3
+ This ruby gem is a CLI (Command Line Interface) tool for developers to easily
4
+ find and run `rspec` on the named files or discovered changed files.
5
+
6
+ ## Installation
7
+
8
+ Install the gem and add to the application's Gemfile by executing:
9
+
10
+ ```bash
11
+ bundle add 'specs_for'
12
+ ```
13
+
14
+ If bundler is not being used to manage dependencies, install the gem by
15
+ executing:
16
+
17
+ ```bash
18
+ gem install specs_for
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ### Command line
24
+
25
+ ```bash
26
+ # Run specs for specific files
27
+ specs-for lib/my_class.rb app/models/user.rb
28
+
29
+ # Run specs for all git-changed files
30
+ specs-for -c
31
+
32
+ # Run specs for changed files with an RSpec tag
33
+ specs-for -c -I # --tag integration
34
+ specs-for -c -T smoke # --tag smoke
35
+
36
+ # Read filenames from stdin
37
+ git diff --name-only HEAD~1 | specs-for -
38
+
39
+ # Show which spec files would run, without running them
40
+ specs-for -s lib/my_class.rb
41
+
42
+ # Dry-run: print the rspec command without executing it
43
+ specs-for -n lib/my_class.rb
44
+ ```
45
+
46
+ ### Path mapping
47
+
48
+ Source files are mapped to spec files by replacing the first `app/` or `lib/`
49
+ segment with `spec/` and appending `_spec` before `.rb`. Component monorepo
50
+ paths preserve the component prefix.
51
+
52
+ | Source file | Spec file |
53
+ | ------------------------------ | ------------------------------------ |
54
+ | `lib/foo/bar.rb` | `spec/foo/bar_spec.rb` |
55
+ | `app/models/user.rb` | `spec/models/user_spec.rb` |
56
+ | `components/mycomp/lib/foo.rb` | `components/mycomp/spec/foo_spec.rb` |
57
+
58
+ Spec files passed as arguments are forwarded to rspec unchanged.
59
+
60
+ ### Options
61
+
62
+ | Flag | Long form | Description |
63
+ | --------- | ---------------------- | --------------------------------------- |
64
+ | `-c` | `--changed` | Use git-changed files (`git status -s`) |
65
+ | `-e` | `--exact` | Match FILE exactly (no fuzzy search) |
66
+ | `-I` | `--integration` | Add `--tag integration` |
67
+ | `-M` | `--manual-integration` | Add `--tag manual_integration` |
68
+ | `-T NAME` | `--tag NAME` | Add `--tag NAME` |
69
+ | `-n` | `--norun` | Print command without running |
70
+ | `-s` | `--show` | Print spec paths without running rspec |
71
+ | `-v` | `--verbose` | Verbose output |
72
+ | `-` | | Read filenames from STDIN |
73
+
74
+ ## Development
75
+
76
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run
77
+ `rake spec` to run the tests. You can also run `bin/console` for an interactive
78
+ prompt that will allow you to experiment.
79
+
80
+ To install this gem onto your local machine, run `bundle exec rake install`. To
81
+ release a new version, update the version number in `version.rb`, and then run
82
+ `bundle exec rake release`, which will create a git tag for the version, push
83
+ git commits and the created tag, and push the `.gem` file to
84
+ [rubygems.org](https://rubygems.org).
85
+
86
+ ## Contributing
87
+
88
+ Bug reports and pull requests are welcome on GitHub at
89
+ https://github.com/aks/specs_for.
90
+
91
+ ## License
92
+
93
+ The gem is available as open source under the terms of the [MIT
94
+ License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/bin/specs-for ADDED
@@ -0,0 +1 @@
1
+ specs_for
data/bin/specs_for ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "specs_for"
6
+
7
+ SpecsFor.new.run
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SpecsFor
4
+ # Finder maps source files to their corresponding spec files.
5
+ class Finder
6
+ SPEC_SUFFIX = "_spec.rb"
7
+
8
+ # Default search paths for spec files, in priority order.
9
+ SPEC_DIRS = %w[spec test].freeze
10
+
11
+ attr_reader :spec_dirs
12
+
13
+ def initialize(spec_dirs: SPEC_DIRS)
14
+ @spec_dirs = Array(spec_dirs)
15
+ end
16
+
17
+ # Given a list of file paths, return the unique set of existing spec files
18
+ # that correspond to those paths.
19
+ #
20
+ # @param files [Array<String>] source (or spec) file paths
21
+ # @return [Array<String>] sorted list of discovered spec file paths
22
+ def find(files)
23
+ files.flat_map { |file| candidates_for(file) }
24
+ .uniq
25
+ .select { |f| File.exist?(f) }
26
+ .sort
27
+ end
28
+
29
+ # Compute candidate spec paths for a single file.
30
+ #
31
+ # @param file [String]
32
+ # @return [Array<String>]
33
+ def candidates_for(file)
34
+ return [file] if spec_file?(file)
35
+
36
+ spec_dirs.flat_map { |dir| spec_paths_in(file, dir) }
37
+ end
38
+
39
+ # Return true when +file+ already looks like a spec file.
40
+ def spec_file?(file)
41
+ file.end_with?(SPEC_SUFFIX)
42
+ end
43
+
44
+ private
45
+
46
+ # Build candidate spec paths by replacing a source prefix with +spec_dir+.
47
+ #
48
+ # Examples
49
+ # spec_paths_in("lib/foo/bar.rb", "spec")
50
+ # #=> ["spec/foo/bar_spec.rb", "spec/lib/foo/bar_spec.rb"]
51
+ def spec_paths_in(file, spec_dir)
52
+ paths = []
53
+
54
+ # Strip a leading source directory component (lib, app, src …) when
55
+ # present, and map into the spec directory.
56
+ parts = file.split(File::SEPARATOR)
57
+ if parts.length > 1 && source_dir?(parts.first)
58
+ inner = File.join(parts[1..])
59
+ paths << File.join(spec_dir, spec_name(inner))
60
+ end
61
+
62
+ # Always add a flat mapping: spec_dir/<basename>_spec.rb
63
+ paths << File.join(spec_dir, spec_name(File.basename(file)))
64
+
65
+ # And a full-path mapping keeping all path components.
66
+ paths << File.join(spec_dir, spec_name(file)) unless file.start_with?(spec_dir)
67
+
68
+ paths.uniq
69
+ end
70
+
71
+ # Convert a plain Ruby filename to its _spec.rb counterpart.
72
+ def spec_name(filename)
73
+ filename.sub(/\.rb\z/, SPEC_SUFFIX)
74
+ end
75
+
76
+ # Heuristic: treat common source directory names as "source dirs".
77
+ def source_dir?(name)
78
+ %w[lib app src].include?(name)
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "finder"
5
+
6
+ class SpecsFor
7
+ # Runner parses CLI arguments, discovers spec files, and executes RSpec.
8
+ class Runner
9
+ attr_reader :files, :options
10
+
11
+ DEFAULT_OPTIONS = {
12
+ verbose: false,
13
+ dry_run: false,
14
+ rspec_options: [],
15
+ }.freeze
16
+
17
+ def initialize(argv = ARGV)
18
+ @argv = argv.dup
19
+ @options = DEFAULT_OPTIONS.dup
20
+ @files = []
21
+ end
22
+
23
+ # Entry-point: parse args, find specs, run rspec.
24
+ # Returns the exit status (Integer).
25
+ def run
26
+ parse_options
27
+ resolve_files
28
+ spec_files = find_spec_files
29
+
30
+ if spec_files.empty?
31
+ warn "specs-for: no spec files found"
32
+ return 1
33
+ end
34
+
35
+ execute_rspec(spec_files)
36
+ end
37
+
38
+ private
39
+
40
+ def parse_options
41
+ parser = OptionParser.new do |opts|
42
+ opts.banner = "Usage: specs-for [options] [file ...]"
43
+
44
+ opts.on("-n", "--dry-run", "Print spec files without running RSpec") do
45
+ @options[:dry_run] = true
46
+ end
47
+
48
+ opts.on("-v", "--verbose", "Show extra output") do
49
+ @options[:verbose] = true
50
+ end
51
+
52
+ opts.on("--rspec-opts OPTS", "Extra options forwarded to RSpec") do |o|
53
+ @options[:rspec_options] = o.split
54
+ end
55
+
56
+ opts.on_tail("-h", "--help", "Show this message") do
57
+ puts opts
58
+ exit 0
59
+ end
60
+
61
+ opts.on_tail("--version", "Show version") do
62
+ require_relative "version"
63
+ puts SpecsFor::VERSION
64
+ exit 0
65
+ end
66
+ end
67
+
68
+ # Separate specs-for flags from positional file arguments.
69
+ @files = parser.parse!(@argv)
70
+ end
71
+
72
+ # When no explicit files are given, fall back to git-changed files.
73
+ def resolve_files
74
+ return unless @files.empty?
75
+
76
+ @files = git_changed_files
77
+ if @options[:verbose]
78
+ puts "specs-for: discovered #{@files.length} changed file(s) from git"
79
+ end
80
+ end
81
+
82
+ def find_spec_files
83
+ finder = Finder.new
84
+ specs = finder.find(@files)
85
+ if @options[:verbose]
86
+ puts "specs-for: found spec files:"
87
+ specs.each { |s| puts " #{s}" }
88
+ end
89
+ specs
90
+ end
91
+
92
+ def execute_rspec(spec_files)
93
+ cmd = ["rspec"] + @options[:rspec_options] + spec_files
94
+ if @options[:dry_run] || @options[:verbose]
95
+ puts "specs-for: #{cmd.join(' ')}"
96
+ end
97
+ return 0 if @options[:dry_run]
98
+
99
+ system(*cmd) ? 0 : 1
100
+ end
101
+
102
+ # Return files changed (added, modified) relative to HEAD using git.
103
+ def git_changed_files
104
+ staged = `git diff --name-only --diff-filter=ACM HEAD 2>/dev/null`.split("\n")
105
+ unstaged = `git diff --name-only --diff-filter=ACM 2>/dev/null`.split("\n")
106
+ (staged + unstaged).uniq.select { |f| f.end_with?(".rb") }
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SpecsFor
4
+ RELEASES = [
5
+ ["0.1.0", "2026-03-25", "Initial release"],
6
+ ["0.2.0", "2026-05-06", "Add support for tags and improved file search"]
7
+ ]
8
+ VERSION = RELEASES.last.first
9
+ end
data/lib/specs_for.rb ADDED
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "specs_for/version"
4
+ require "open3"
5
+ require "optparse"
6
+ require "shellwords"
7
+
8
+ # Finds and runs RSpec spec files corresponding to a given set of source files.
9
+ # rubocop:disable Metrics/ClassLength
10
+ class SpecsFor
11
+ PROG = File.basename($PROGRAM_NAME)
12
+ VALID_TAG = /\A[a-zA-Z_][a-zA-Z0-9_:]*\z/
13
+
14
+ def initialize(files: [], specs: [], opts: nil)
15
+ @file_args = []
16
+ @files = files
17
+ @specs = specs
18
+ @opts = opts
19
+ end
20
+
21
+ attr_reader :file_args, :files, :specs
22
+
23
+ def exact?; opts[:exact]; end
24
+ def norun?; opts[:norun]; end
25
+ def verbose?; opts[:verbose]; end
26
+ def debug?; opts[:debug]; end
27
+ def show?; opts[:show]; end
28
+ def tags; opts[:tags]; end
29
+ def run?; opts[:run]; end
30
+ def changed?; opts[:changed]; end
31
+ def filenames_from_stdin?; opts[:filenames_from_stdin]; end
32
+
33
+ def parse_options(args = ARGV)
34
+ @opts = { tags: [] }
35
+ parser = OptionParser.new do |opt|
36
+ opt.banner = <<~BANNER
37
+ Usage: #{PROG} [options] [- | FILE ...]
38
+
39
+ Finds and runs RSpec specs for the given Ruby source files. Pass '-' to
40
+ read filenames from STDIN. Spec files are passed through unchanged; source
41
+ files are mapped to spec/ paths by replacing app/ or lib/ with spec/.
42
+
43
+ Uses 'ag' for file search when available, otherwise Dir[].
44
+ Prefixes rspec with 'asdf exec' when ruby is managed by asdf.
45
+
46
+ Examples:
47
+ #{PROG} lib/foo.rb # run spec for one file
48
+ #{PROG} -c # run specs for git-changed files
49
+ #{PROG} -c -I # same, with --tag integration
50
+ git diff --name-only | #{PROG} - # pipe filenames from stdin
51
+
52
+ Options:
53
+ BANNER
54
+ opt.on('-h', '--help') { warn opt; exit }
55
+ opt.on('-c', '--changed', 'Use git-changed files')
56
+ opt.on('-d', '--debug', 'Debug mode (opens pry if available)')
57
+ opt.on('-e', '--exact', 'Match FILE exactly')
58
+ opt.on('-I', '--integration', 'Add --tag integration') { add_tag 'integration' }
59
+ opt.on('-M', '--manual-integration', 'Add --tag manual_integration') { add_tag 'manual_integration' }
60
+ opt.on('-n', '--norun', 'Show command, do not execute')
61
+ opt.on('-r', '--run', 'Force run rspec (default unless -n or -s)')
62
+ opt.on('-s', '--show', 'Print spec paths, do not run rspec')
63
+ opt.on('-TNAME', '--tag NAME', 'Add --tag NAME') { |name| add_tag name }
64
+ opt.on('-v', '--verbose', 'Be verbose')
65
+ end
66
+ parser.parse!(args, into: @opts)
67
+ @opts[:filenames_from_stdin] = true if args.delete('-')
68
+ @file_args = args
69
+ if file_args.empty? && !filenames_from_stdin? && !changed?
70
+ warn "Error: at least one FILE argument is required. Use '-' to read filenames from STDIN."
71
+ warn parser
72
+ exit 1
73
+ end
74
+ @opts[:run] = true if !@opts[:norun] && !@opts[:show] && @opts[:run].nil?
75
+ if @opts[:debug]
76
+ begin
77
+ require 'pry'
78
+ rescue LoadError
79
+ warn "debug: pry not available, continuing without it"
80
+ @opts[:debug] = false
81
+ end
82
+ end
83
+ end
84
+
85
+ def opts
86
+ parse_options unless @opts
87
+ @opts
88
+ end
89
+
90
+ def run(args = ARGV)
91
+ parse_options(args)
92
+ collect_file_names
93
+ if file_args.empty?
94
+ return if changed?
95
+ warn "Error: no input filenames provided. Pass FILE args or pipe at least one filename to '-'."
96
+ exit 1
97
+ end
98
+ collect_files
99
+ collect_specs
100
+ run? ? run_specs : show_specs
101
+ end
102
+
103
+ # Parses a `git status -s` output block and returns the relevant filenames.
104
+ def filter_git_status(status_output)
105
+ status_output.each_line(chomp: true).filter_map do |line|
106
+ # git status -s uses a fixed two-char XY status field followed by a space
107
+ status = line[0, 2].strip
108
+ rest = line[3..]
109
+ next if rest.nil? || rest.empty?
110
+
111
+ case status
112
+ when 'M', 'A', '??' then rest
113
+ when 'R' then rest.split(' -> ', 2).last # "old -> new", take new name
114
+ when 'D' then nil # deleted — skip
115
+ end
116
+ end
117
+ end
118
+
119
+ # Parses either a quoted path (allows spaces) or an unquoted path from the
120
+ # start of +str+, returning [filename, remainder] or nil if nothing matched.
121
+ def extract_filename(str)
122
+ match = str.match(/^\s*(?:"([^"]*)"|(\S+))\s*(.*)$/) if str
123
+ [match[1] || match[2], match[3]] if match
124
+ end
125
+
126
+ # Converts a source file path to its spec path and appends it to @specs if
127
+ # the spec file exists. Handles app/**/*.rb, lib/**/*.rb, and the component
128
+ # monorepo layout components/NAME/app/**/*.rb / components/NAME/lib/**/*.rb,
129
+ # preserving the component prefix.
130
+ def collect_spec_for(file)
131
+ spec_file = file
132
+ .sub(%r{(?<=/|^)((?:components/\w+/)?)(?:app|lib)/}, '\1spec/')
133
+ .sub(/\.rb$/, '_spec.rb')
134
+ if File.exist?(spec_file)
135
+ @specs << spec_file
136
+ warn "==> Found spec for #{file}" if verbose?
137
+ elsif verbose?
138
+ warn "==> No spec file for #{file}"
139
+ end
140
+ end
141
+
142
+ def collect_specs
143
+ not_found = []
144
+ files.each do |file|
145
+ if file.end_with?('_spec.rb')
146
+ @specs << file
147
+ elsif file.match?(%r{(?:^|/)(?:components/\w+/)?(?:app|lib)/})
148
+ collect_spec_for(file)
149
+ elsif Dir.exist?(file)
150
+ # skip directories
151
+ else
152
+ not_found << file if verbose?
153
+ end
154
+ end
155
+ warn "==> No spec file for:\n #{not_found.join("\n ")}" if verbose? && not_found.any?
156
+ @specs = @specs.uniq.sort
157
+ end
158
+
159
+ def tag_opts
160
+ tag_argv.shelljoin.strip
161
+ end
162
+
163
+ def use_asdf?
164
+ ruby_path.include?('/.asdf/')
165
+ end
166
+
167
+ def rspec_command
168
+ argv = ['bundle', 'exec', 'rspec', *tag_argv, *specs]
169
+ argv.unshift('asdf', 'exec') if use_asdf?
170
+ argv
171
+ end
172
+
173
+ private
174
+
175
+ def add_tag(name)
176
+ unless name.match?(VALID_TAG)
177
+ warn "Invalid tag name ignored: #{name.inspect}"
178
+ return
179
+ end
180
+ @opts[:tags] << name unless @opts[:tags].include?(name)
181
+ end
182
+
183
+ def collect_file_names
184
+ file_args.concat(local_changed_filenames) if changed?
185
+ file_args.concat(read_filenames_from_stdin) if filenames_from_stdin?
186
+ end
187
+
188
+ def collect_files(args = file_args)
189
+ args.each do |file_arg|
190
+ if Dir.exist?(file_arg)
191
+ collect_files(Dir.children(file_arg).map { |f| File.join(file_arg, f) })
192
+ elsif File.exist?(file_arg)
193
+ @files << file_arg
194
+ else
195
+ found = find_file(file_arg)
196
+ if found.empty?
197
+ warn "==> Could not find: #{file_arg}"
198
+ else
199
+ show_files(found, 'Found file') if verbose?
200
+ @files.concat(found)
201
+ end
202
+ end
203
+ end
204
+ end
205
+
206
+ def find_file(file)
207
+ ag_present? ? find_file_with_ag(file) : find_file_with_dir(file)
208
+ end
209
+
210
+ def ag_present?
211
+ return @ag_present unless @ag_present.nil?
212
+ @ag_present = system('which', 'ag', out: IO::NULL, err: IO::NULL)
213
+ end
214
+
215
+ def build_file_pattern(file, mode:)
216
+ dir = File.dirname(file)
217
+ base = File.basename(file)
218
+ ext = File.extname(base)
219
+ plain = file.count('/').zero?
220
+
221
+ pat =
222
+ if plain && exact?
223
+ mode == :regex ? base : "**/#{base}"
224
+ elsif plain
225
+ mode == :regex ? ".*#{base}" : "**/*#{base}"
226
+ elsif exact?
227
+ mode == :regex ? "(?:^|/)#{dir}/#{base}$" : "**/#{dir}/#{base}"
228
+ else
229
+ mode == :regex ? ".*#{dir}/.*#{base}" : "**/#{dir}/*#{base}"
230
+ end
231
+
232
+ return pat if ext
233
+
234
+ pat + (exact? ? (mode == :regex ? '\\.rb$' : '.rb')
235
+ : (mode == :regex ? '*\\.rb$' : '*.rb'))
236
+ end
237
+
238
+ def find_file_with_ag(file)
239
+ binding.pry if debug? # rubocop:disable Lint/Debugger
240
+ stdout, = Open3.capture2('ag', '-S', '-g', build_file_pattern(file, mode: :regex))
241
+ stdout.split("\n")
242
+ end
243
+
244
+ def find_file_with_dir(file)
245
+ binding.pry if debug? # rubocop:disable Lint/Debugger
246
+ Dir[build_file_pattern(file, mode: :glob)]
247
+ end
248
+
249
+ def show_files(files, label = nil)
250
+ if files.size == 1
251
+ warn "#{label} #{files.first}"
252
+ else
253
+ warn "#{label}s:"
254
+ warn " #{files.sort.join("\n ")}"
255
+ end
256
+ end
257
+
258
+ def show_specs
259
+ puts specs.join("\n")
260
+ end
261
+
262
+ def run_specs
263
+ argv = rspec_command
264
+ if norun?
265
+ warn "(norun) #{argv.shelljoin}"
266
+ else
267
+ warn "--> #{argv.shelljoin}"
268
+ system(*argv)
269
+ end
270
+ end
271
+
272
+ def tag_argv
273
+ tags.compact.flat_map { |tag| ['--tag', tag] }
274
+ end
275
+
276
+ def ruby_path
277
+ @ruby_path ||= Open3.capture2('which', 'ruby').first.chomp
278
+ end
279
+
280
+ def local_changed_filenames
281
+ stdout, = Open3.capture2('git', 'status', '-s')
282
+ filter_git_status(stdout).reject { |file| ignored_by_git?(file) }
283
+ end
284
+
285
+ def ignored_by_git?(file)
286
+ system('git', 'check-ignore', '-q', '--', file, out: IO::NULL, err: IO::NULL)
287
+ end
288
+
289
+ def read_filenames_from_stdin
290
+ $stdin.each_line.flat_map do |line|
291
+ filenames = []
292
+ loop do
293
+ result = extract_filename(line)
294
+ break unless result
295
+ filename, line = result
296
+ filenames << filename
297
+ end
298
+ filenames
299
+ end
300
+ end
301
+ end
302
+ # rubocop:enable Metrics/ClassLength
data/sig/specs_for.rbs ADDED
@@ -0,0 +1,31 @@
1
+ class SpecsFor
2
+ VERSION: String
3
+ VALID_TAG: Regexp
4
+
5
+ attr_reader file_args: Array[String]
6
+ attr_reader files: Array[String]
7
+ attr_reader specs: Array[String]
8
+
9
+ def initialize: (?files: Array[String], ?specs: Array[String], ?opts: Hash[Symbol, untyped]?) -> void
10
+ def run: (?Array[String] args) -> void
11
+ def parse_options: (?Array[String] args) -> void
12
+ def opts: () -> Hash[Symbol, untyped]
13
+
14
+ def exact?: () -> bool?
15
+ def norun?: () -> bool?
16
+ def verbose?: () -> bool?
17
+ def debug?: () -> bool?
18
+ def show?: () -> bool?
19
+ def tags: () -> Array[String]
20
+ def run?: () -> bool?
21
+ def changed?: () -> bool?
22
+ def filenames_from_stdin?: () -> bool?
23
+
24
+ def filter_git_status: (String status_output) -> Array[String]
25
+ def extract_filename: (String? str) -> [String, String]?
26
+ def collect_spec_for: (String file) -> void
27
+ def collect_specs: () -> void
28
+ def tag_opts: () -> String
29
+ def use_asdf?: () -> bool
30
+ def rspec_command: () -> Array[String]
31
+ end
metadata ADDED
@@ -0,0 +1,213 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: specs_for
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Alan Stebbens
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: bundler
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 4.0.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 4.0.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: json
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 2.19.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 2.19.0
40
+ - !ruby/object:Gem::Dependency
41
+ name: psych
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 5.3.1
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 5.3.1
54
+ - !ruby/object:Gem::Dependency
55
+ name: fuubar
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 2.5.0
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 2.5.0
68
+ - !ruby/object:Gem::Dependency
69
+ name: irb
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rake
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: 13.0.0
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: 13.0.0
96
+ - !ruby/object:Gem::Dependency
97
+ name: rspec
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: 3.0.0
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: 3.0.0
110
+ - !ruby/object:Gem::Dependency
111
+ name: rubocop
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: 1.0.0
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: 1.0.0
124
+ - !ruby/object:Gem::Dependency
125
+ name: simplecov
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: 0.22.0
131
+ type: :development
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: 0.22.0
138
+ - !ruby/object:Gem::Dependency
139
+ name: yard
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: 0.9.43
145
+ type: :development
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: 0.9.43
152
+ - !ruby/object:Gem::Dependency
153
+ name: yard-rspec
154
+ requirement: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - ">="
157
+ - !ruby/object:Gem::Version
158
+ version: '0.1'
159
+ type: :development
160
+ prerelease: false
161
+ version_requirements: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - ">="
164
+ - !ruby/object:Gem::Version
165
+ version: '0.1'
166
+ description: A CLI tool that maps Ruby source files to their RSpec counterparts and
167
+ runs them. Supports git-changed files, stdin file lists, component monorepo layouts,
168
+ and RSpec tag filtering.
169
+ email:
170
+ - aks@stebbens.org
171
+ executables:
172
+ - specs-for
173
+ - specs_for
174
+ extensions: []
175
+ extra_rdoc_files: []
176
+ files:
177
+ - CHANGELOG.md
178
+ - CLAUDE.md
179
+ - LICENSE.txt
180
+ - README.md
181
+ - Rakefile
182
+ - bin/specs-for
183
+ - bin/specs_for
184
+ - lib/specs_for.rb
185
+ - lib/specs_for/finder.rb
186
+ - lib/specs_for/runner.rb
187
+ - lib/specs_for/version.rb
188
+ - sig/specs_for.rbs
189
+ homepage: https://github.com/aks/specs-for
190
+ licenses:
191
+ - MIT
192
+ metadata:
193
+ homepage_uri: https://github.com/aks/specs-for
194
+ source_code_uri: https://github.com/aks/specs-for
195
+ changelog_uri: https://github.com/aks/specs-for/blob/main/CHANGELOG.md
196
+ rdoc_options: []
197
+ require_paths:
198
+ - lib
199
+ required_ruby_version: !ruby/object:Gem::Requirement
200
+ requirements:
201
+ - - ">="
202
+ - !ruby/object:Gem::Version
203
+ version: 3.3.10
204
+ required_rubygems_version: !ruby/object:Gem::Requirement
205
+ requirements:
206
+ - - ">="
207
+ - !ruby/object:Gem::Version
208
+ version: '0'
209
+ requirements: []
210
+ rubygems_version: 4.0.11
211
+ specification_version: 4
212
+ summary: Produce and run rspec on a set of given or discovered filenames
213
+ test_files: []