pinspec 0.1.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7f80ae1282baf0c4d24f694ced4a28e3333bd2db013a6b5a50c64dbf24349e51
4
- data.tar.gz: 3a970d963883ed690524562afa726ab0690aa6f5325bd3e65400c267b34ffb0f
3
+ metadata.gz: 3725fabfaf5ca20380e8006be8da9324aa2e1f044dba71ee7cd867360a42fe57
4
+ data.tar.gz: 4d83085aaa24d6b1eb97577f32fdc8bb4696c28469bacfab8187c04cd12f3e5a
5
5
  SHA512:
6
- metadata.gz: e8d0773aa7d21161ba1a6b2c2cd64634dc0edeaca7bd9b089e92db7ecb76b9cc77e091316f5a97fa008b25cd790dd8e155777eede702138f14e08ebd9019d93f
7
- data.tar.gz: 76952dd81076d9e47aef99bbea8b181c6946312127a1360ea3ccd8ba7f0bbccfb25a511104a741292c920924e04803c35eb7d1f19e9187180954859f51430523
6
+ metadata.gz: 62faf9d5c24304fe2d122d05ddf17634185728764e4a59dd5bdbba8c953e718d748497abd562d95e77ed35c8dc4b1477a1bc0732fb403498f846498f3572cbd3
7
+ data.tar.gz: 5af0cf46d7730057402c1b3dcc0679ec495be239b94fba0aecae7e21a1029fdedfa15f053404f7f52e5594e9706522820cac109fe68abf9de24f7da323f9a121
data/CHANGELOG.md CHANGED
@@ -1,5 +1,83 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 - 2026-08-15
4
+
5
+ Ergonomics. 0.1.0 worked, but a run against a real application needed six lines of
6
+ environment before it would start, and almost none of it was intent.
7
+
8
+ ### The app's Ruby is detected, not declared
9
+
10
+ pinspec reads the target application's `.ruby-version` or `.tool-versions` and
11
+ locates that Ruby under rvm, rbenv, asdf, mise or chruby on every run. Only
12
+ bundler's variables are scrubbed from the child process now; `GEM_HOME` and
13
+ `GEM_PATH` are left alone, which is what previously forced every invocation to hand
14
+ the application's gemset back by hand. When the declared Ruby cannot be found,
15
+ the failure names the version, the one in use, and the exact `--app-env` line to
16
+ pass.
17
+
18
+ A patch-level difference is treated as the same runtime: 3.4.2 and 3.4.6 run the
19
+ same code, and relocating for it would fail on a machine that has only one.
20
+
21
+ ### `.pinspec.yml`
22
+
23
+ `pinspec init` writes it. It holds the flags you would otherwise repeat - `cases`,
24
+ `boots`, `sample`, `verify-level`, `test-command`, and an `env` mapping for what
25
+ pinspec cannot work out, such as database credentials. A flag on the command line
26
+ always wins. An unknown key is an error naming the key and listing the valid ones,
27
+ rather than silence that reads as "the setting does not work".
28
+
29
+ The detected runtime is deliberately **not** written to that file: recording one
30
+ machine's `PATH` bakes in a layout that goes stale the moment the app changes Ruby,
31
+ and detection re-runs anyway.
32
+
33
+ ### Targets
34
+
35
+ - A bare file assumes `#call`: `pinspec pin app/services/invoice_calculator.rb`.
36
+ - `--app-env` is repeatable (`--app-env A=1 --app-env B=2`) instead of an array
37
+ option, so the target can appear anywhere on the line. As an array option it
38
+ silently swallowed the positional target unless that came first.
39
+
40
+ **The 0.1.0 form keeps working.** `--app-env A=1 B=2 C=3` was what 0.1.0
41
+ documented, and changing the option type would have silently reinterpreted every
42
+ existing invocation rather than failing on it. Trailing `KEY=VALUE` pairs are
43
+ still collected, the target is whichever argument is not a pair, and using the old
44
+ form prints a note suggesting the new one. Nothing anyone scripted against 0.1.0
45
+ needs to change.
46
+ - **A directory pins everything under it.** Refusals are reported and the run
47
+ continues, since one target that takes a block should not end a run over forty.
48
+ The summary separates what was pinned, what was skipped and why, and what failed
49
+ verification. Exits non-zero when nothing pinned.
50
+
51
+ ### Output
52
+
53
+ Default output is the target, the stable count, what was pinned, the three verify
54
+ results and the report path. The plan id, compared fields and per-case detail move
55
+ behind `--verbose`. `pin` leads the command list, and `plan` and `capture` are
56
+ labelled diagnostics.
57
+
58
+ ### Compatibility
59
+
60
+ Nothing from 0.1.0 breaks. Both `--app-env` forms are accepted, `FILE#METHOD` still
61
+ works alongside the new bare-file shorthand, every flag keeps its name and meaning,
62
+ and `.pinspec.yml` is optional - an application without one behaves exactly as it
63
+ did, except that its Ruby is now found automatically.
64
+
65
+ ### Fixed
66
+
67
+ - **Two pinspec runs against the same application corrupted each other.** The probe
68
+ was written to a fixed `tmp/pinspec/probe.rb`, so a second run overwrote it between
69
+ the first run's two boots. The failure was silent rather than loud: the stability
70
+ filter compared one target's observations against another's and reported the target
71
+ as unstable, or crashed looking up a case id belonging to a different corpus. Each
72
+ run now writes its own probe, named after the process, and removes it on success -
73
+ keeping it after a failure, where it is the evidence. Found by running two captures
74
+ against one application at once.
75
+ - Batch discovery matched its skip patterns against the absolute path, so an
76
+ application living anywhere under a directory named `spec` had every file skipped.
77
+ Patterns now match the path relative to the directory being pinned.
78
+ - `analyze` did not read `.pinspec.yml`, so a typo in it stayed silent for the
79
+ command people run first.
80
+
3
81
  ## 0.1.0 - 2026-08-13
4
82
 
5
83
  First release.
data/README.md CHANGED
@@ -8,24 +8,28 @@ code does today, and then verifies that file passes in your app's own test
8
8
  environment.
9
9
 
10
10
  ```bash
11
- pinspec pin app/services/invoice_calculator.rb#call --app .
11
+ cd myapp
12
+ pinspec pin app/services/invoice_calculator.rb
12
13
  ```
13
14
 
14
15
  ```
15
16
  capture InvoiceCalculator#call
16
- runs 2 boots
17
- stable 2 of 2
17
+ stable 3 of 3 cases, over 2 boots
18
18
 
19
19
  emitted spec/characterization/invoice_calculator_call_spec.rb
20
- pinned c001, c002
21
- aspects 2 return, 2 jobs
20
+ pinned 3 case(s): 3 return, 3 jobs
22
21
 
23
22
  verify
24
- isolated green (4 examples)
25
- hostile green (4 examples)
26
- neighbored green (4 examples)
23
+ isolated green (6 examples)
24
+ hostile green (6 examples)
25
+ neighbored green (6 examples)
26
+
27
+ report tmp/pinspec/report.md
27
28
  ```
28
29
 
30
+ `--verbose` adds the plan id, the fields compared for stability, and a line per
31
+ pinned case.
32
+
29
33
  A pin freezes current behaviour. It is not a claim that the behaviour is correct —
30
34
  bugs get pinned on purpose, so a refactor cannot change them silently.
31
35
 
@@ -39,21 +43,57 @@ Ruby >= 3.2. Rails >= 6.0 in the target app. PostgreSQL, MySQL and SQLite all wo
39
43
  pinspec adds no database gem, because everything that touches your data runs inside
40
44
  your app through `rails runner`.
41
45
 
46
+ Your app almost certainly runs on a different Ruby than pinspec does. That is fine
47
+ and needs no configuration: pinspec reads your app's `.ruby-version` or
48
+ `.tool-versions` and finds that Ruby under rvm, rbenv, asdf, mise or chruby on each
49
+ run. If it cannot, it tells you the exact `--app-env` line to pass.
50
+
51
+ For anything pinspec cannot work out for itself — database credentials, a feature
52
+ flag — record it once:
53
+
54
+ ```bash
55
+ pinspec init
56
+ ```
57
+
58
+ That writes `.pinspec.yml`, which holds the flags you would otherwise repeat. A flag
59
+ on the command line always wins.
60
+
42
61
  ## Commands
43
62
 
44
63
  | Command | What it does |
45
64
  | --- | --- |
46
- | `pinspec analyze [APP]` | App profile, schema, factories and hazards. Reads files only no boot, no database. |
47
- | `pinspec plan FILE#METHOD --app PATH` | The world it would build, and the arguments it would pass. Still no execution. |
48
- | `pinspec capture FILE#METHOD --app PATH` | Runs the probe in your app, writes `observations.json`. |
49
- | `pinspec pin FILE#METHOD --app PATH` | Capture, emit the spec, verify it. |
50
- | `pinspec validate FILE#METHOD --app PATH` | Mutation-scores the pin, one aspect at a time. Needs Ruby >= 3.4 and `mutineer`. |
51
- | `pinspec report --app PATH` | Prints the last run's markdown report. |
65
+ | `pinspec pin TARGET` | Capture, emit the spec, verify it. This is the one you want. |
66
+ | `pinspec init` | Write `.pinspec.yml` so later runs need no flags. |
67
+ | `pinspec analyze` | App profile, schema, factories and hazards. Reads files only — no boot, no database. |
68
+ | `pinspec validate TARGET` | Mutation-scores the pin, one aspect at a time. Needs Ruby >= 3.4 and `mutineer`. |
69
+ | `pinspec report` | Prints the last run's markdown report. |
70
+ | `pinspec plan TARGET` | Diagnostic: the world it would build, without running anything. |
71
+ | `pinspec capture TARGET` | Diagnostic: run the probe only, and write `observations.json`. |
52
72
 
53
- Useful flags: `--cases N`, `--boots N`, `--sample` (read real rows from your
54
- development database), `--no-redact`, `--force`, `--app-env KEY=VALUE`.
73
+ `TARGET` is a file, a `FILE#METHOD`, or a directory:
55
74
 
56
- The target comes first. `--app-env` is an array option and will swallow it otherwise.
75
+ ```bash
76
+ pinspec pin app/services/invoice_calculator.rb # assumes #call
77
+ pinspec pin app/services/invoice_calculator.rb#total # a different method
78
+ pinspec pin app/services # everything under it
79
+ ```
80
+
81
+ Pinning a directory keeps going when a target is refused, and prints one summary:
82
+
83
+ ```
84
+ pinned invoice_calculator.rb 2 case(s), verified
85
+ skipped report_builder.rb BlockRequired
86
+ pinned status_reporter.rb 2 case(s), verified
87
+
88
+ pinned 2 of 3
89
+ skipped 1
90
+ ```
91
+
92
+ Useful flags: `--cases N`, `--boots N`, `--sample` (read real rows from your
93
+ development database), `--no-redact`, `--force`, `--verbose`, and `--app-env KEY=VALUE`
94
+ for the rare case where the runtime cannot be detected. `--app-env` is repeatable;
95
+ the older `--app-env A=1 B=2` form is still accepted. Anything you find yourself
96
+ repeating belongs in `.pinspec.yml`.
57
97
 
58
98
  ## Verification
59
99
 
@@ -112,6 +152,7 @@ sources are hashed, so a committed spec does not map back to production rows.
112
152
  | `db/structure.sql` instead of `db/schema.rb` | `SchemaFormatUnsupported` | 6 |
113
153
  | Rails below 6.0 | `UnsupportedRailsVersion` | 10 |
114
154
  | no case was stable across boots | `NothingStableToPin` | 8 |
155
+ | `.pinspec.yml` has an unknown key or is not valid YAML | `ConfigInvalid` | 13 |
115
156
 
116
157
  It will not pass `nil` for a model it could not build: the target would raise on nil,
117
158
  and that error would be pinned as though your application produced it.
@@ -60,14 +60,19 @@ module Pinspec
60
60
  new(file_path, method_name).parse
61
61
  end
62
62
 
63
+ DEFAULT_METHOD = "call"
64
+
63
65
  def split_target(target)
64
- unless target.to_s.include?("#")
66
+ text = target.to_s
67
+ return text.split("#", 2) if text.include?("#")
68
+
69
+ unless text.end_with?(".rb")
65
70
  raise ArgumentError,
66
- "target must be FILE#METHOD (got #{target.inspect}); " \
67
- "e.g. app/services/invoice_calculator.rb#call"
71
+ "target must be a Ruby file, optionally with #METHOD " \
72
+ "(got #{target.inspect}); e.g. app/services/invoice_calculator.rb#call"
68
73
  end
69
74
 
70
- target.split("#", 2)
75
+ [text, DEFAULT_METHOD]
71
76
  end
72
77
  end
73
78
 
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinspec
4
+ class Batch
5
+ SKIP_PATTERNS = [%r{/concerns/}, %r{_spec\.rb\z}, %r{/spec/}, %r{/test/}].freeze
6
+
7
+ Outcome = Data.define(:file, :target, :status, :detail, :pinned, :spec_path) do
8
+ def pinned?
9
+ status == :pinned
10
+ end
11
+
12
+ def refused?
13
+ status == :refused
14
+ end
15
+ end
16
+
17
+ Report = Data.define(:outcomes) do
18
+ def pinned
19
+ outcomes.select(&:pinned?)
20
+ end
21
+
22
+ def refused
23
+ outcomes.select(&:refused?)
24
+ end
25
+
26
+ def failed
27
+ outcomes.reject { |outcome| outcome.pinned? || outcome.refused? }
28
+ end
29
+
30
+ def anything_pinned?
31
+ !pinned.empty?
32
+ end
33
+ end
34
+
35
+ # Patterns match the path RELATIVE to the directory being pinned. Matching the
36
+ # absolute path means an app that happens to live under a directory called
37
+ # `spec` has every one of its files skipped.
38
+ def self.targets_in(path)
39
+ return [path] if File.file?(path)
40
+
41
+ root = File.expand_path(path)
42
+
43
+ Dir.glob(File.join(root, "**", "*.rb")).reject do |file|
44
+ relative = file.delete_prefix("#{root}/")
45
+
46
+ SKIP_PATTERNS.any? { |pattern| "/#{relative}".match?(pattern) }
47
+ end.sort
48
+ end
49
+
50
+ def initialize(files, &pin)
51
+ @files = files
52
+ @pin = pin
53
+ end
54
+
55
+ # A refusal is information, not a stop: one target that takes a block must not
56
+ # end a run over a directory of forty.
57
+ def run
58
+ Report.new(outcomes: @files.map { |file| attempt(file) })
59
+ end
60
+
61
+ private
62
+
63
+ def attempt(file)
64
+ @pin.call(file)
65
+ rescue UnresolvableSetup, BlockRequired, AmbiguousTarget, TargetNotFound,
66
+ NothingStableToPin, UnsupportedRailsVersion => e
67
+ Outcome.new(file: file, target: nil, status: :refused, detail: reason_for(e),
68
+ pinned: 0, spec_path: nil)
69
+ rescue VerifyFailed, ProbeFailure => e
70
+ Outcome.new(file: file, target: nil, status: :failed, detail: e.message.lines.first.to_s.strip,
71
+ pinned: 0, spec_path: nil)
72
+ end
73
+
74
+ def reason_for(error)
75
+ return "#{error.class.name.split('::').last}(#{error.reason})" if error.respond_to?(:reason)
76
+
77
+ error.class.name.split("::").last
78
+ end
79
+ end
80
+ end
data/lib/pinspec/cli.rb CHANGED
@@ -4,10 +4,25 @@ require "thor"
4
4
 
5
5
  module Pinspec
6
6
  class CLI < Thor
7
+ class_option :verbose, type: :boolean, default: false, aliases: "-V",
8
+ desc: "show the plan id, compared fields and per-case detail"
9
+
7
10
  def self.exit_on_failure?
8
11
  true
9
12
  end
10
13
 
14
+ ORDER = %w[pin init analyze validate report plan capture version].freeze
15
+
16
+ # Thor sorts the command list alphabetically after building it, which buries the
17
+ # one verb most people want between `init` and `plan`, and gives the two
18
+ # diagnostics equal billing with the product.
19
+ def self.sort_commands!(list)
20
+ list.sort_by! do |usage, _|
21
+ name = usage.to_s.split(/\s+/)[1].to_s
22
+ [ORDER.index(name) || ORDER.size, name]
23
+ end
24
+ end
25
+
11
26
  desc "version", "Print the pinspec version"
12
27
  def version
13
28
  puts "pinspec #{Pinspec::VERSION} " \
@@ -15,7 +30,44 @@ module Pinspec
15
30
  end
16
31
  map %w[--version -v] => :version
17
32
 
18
- desc "plan FILE#METHOD", "Resolve a target and print the SetupPlan that would build its world"
33
+ desc "init [APP_PATH]", "Write .pinspec.yml so later runs need no flags"
34
+ method_option :force, type: :boolean, default: false, desc: "overwrite an existing .pinspec.yml"
35
+ def init(app_path = ".")
36
+ guarded do
37
+ path = File.join(app_path, Config::FILENAME)
38
+
39
+ if File.file?(path) && !options[:force]
40
+ raise ConfigInvalid, "#{path} already exists. Pass --force to overwrite it."
41
+ end
42
+
43
+ runtime = Runner::Runtime.for(app_path)
44
+ profile = Analyzer::AppProfileReader.read(app_path)
45
+
46
+ # Deliberately NOT the detected environment. Writing the resolved PATH into a
47
+ # committed file bakes in one machine's layout and goes stale the moment the
48
+ # app changes Ruby - and detection re-runs on every invocation anyway. `env`
49
+ # is for what pinspec cannot work out: database credentials, feature flags.
50
+ File.write(path, Config.new(app_path, {
51
+ "cases" => Inputs::Corpus::DEFAULT_MAX_CASES,
52
+ "boots" => 2
53
+ }, path).to_yaml_document)
54
+
55
+ row "wrote", path
56
+ row "rails", profile.rails_version || "unknown"
57
+ row "ruby", runtime.ruby_version
58
+ row "runtime", runtime.detected? ? "#{runtime.manager}, detected on each run" : "this shell's Ruby"
59
+ puts
60
+
61
+ if runtime.note
62
+ puts runtime.note
63
+ puts
64
+ end
65
+
66
+ puts "Now: pinspec pin app/services/your_service.rb"
67
+ end
68
+ end
69
+
70
+ desc "plan TARGET", "Diagnostic: the world pinspec would build, without running anything"
19
71
  method_option :app, type: :string, default: ".", desc: "target app root"
20
72
  method_option :cases, type: :numeric, default: Inputs::Corpus::DEFAULT_MAX_CASES,
21
73
  desc: "max input cases per method"
@@ -33,7 +85,7 @@ module Pinspec
33
85
  target: target_profile,
34
86
  plan: setup_plan,
35
87
  schema: app_profile.schema,
36
- max_cases: options[:cases]
88
+ max_cases: setting('cases', Inputs::Corpus::DEFAULT_MAX_CASES)
37
89
  )
38
90
 
39
91
  print_plan(setup_plan)
@@ -45,6 +97,7 @@ module Pinspec
45
97
  desc "analyze [APP_PATH]", "App profile: schema, factories, auth, tenancy, hazards"
46
98
  def analyze(app_path = ".")
47
99
  guarded do
100
+ Config.load(app_path)
48
101
  profile = Analyzer::AppProfileReader.read(app_path)
49
102
 
50
103
  print_app(profile)
@@ -56,32 +109,33 @@ module Pinspec
56
109
  end
57
110
  end
58
111
 
59
- desc "capture FILE#METHOD", "Run the probe, write observations.json"
112
+ desc "capture TARGET", "Diagnostic: run the probe only, and write observations.json"
60
113
  method_option :app, type: :string, default: ".", desc: "target app root"
61
114
  method_option :cases, type: :numeric, default: Inputs::Corpus::DEFAULT_MAX_CASES
62
115
  method_option :boots, type: :numeric, default: 2,
63
116
  desc: "probe boots; 2 is the default because one process shares warm caches"
64
117
  method_option :"compare-sql", type: :boolean, default: false,
65
118
  desc: "include SQL fingerprints in the stability decision"
66
- method_option :"app-env", type: :array, default: [], banner: "KEY=VALUE",
119
+ method_option :"app-env", type: :string, repeatable: true, banner: "KEY=VALUE",
67
120
  desc: "environment for the app's own runtime (when it is not this shell's Ruby)"
68
121
  method_option :sample, type: :boolean, default: false,
69
122
  desc: "read real rows through a generated read-only script in the app"
70
123
  method_option :"no-redact", type: :boolean, default: false,
71
124
  desc: "do NOT rewrite personal data in sampled rows (they land in a committed file)"
72
- def capture(target)
125
+ def capture(*args)
73
126
  guarded do
127
+ target = target_from(args)
74
128
  file, method = Analyzer::TargetParser.split_target(target)
75
129
 
76
130
  result = Runner::Capture.new(
77
131
  app_root: options[:app],
78
132
  target: file,
79
133
  method: method,
80
- max_cases: options[:cases],
81
- boots: options[:boots],
82
- compare_sql: options[:"compare-sql"],
134
+ max_cases: setting('cases', Inputs::Corpus::DEFAULT_MAX_CASES),
135
+ boots: setting('boots', 2),
136
+ compare_sql: setting('compare-sql', false),
83
137
  sandbox_env: app_env,
84
- sample: options[:sample],
138
+ sample: setting('sample', false),
85
139
  redact: !options[:"no-redact"]
86
140
  ).run
87
141
 
@@ -91,7 +145,7 @@ module Pinspec
91
145
  end
92
146
  end
93
147
 
94
- desc "pin FILE#METHOD", "Plan + capture + emit + verify"
148
+ desc "pin TARGET", "Capture, emit and verify. TARGET is FILE, FILE#METHOD or a directory"
95
149
  method_option :app, type: :string, default: ".", desc: "target app root"
96
150
  method_option :cases, type: :numeric, default: Inputs::Corpus::DEFAULT_MAX_CASES
97
151
  method_option :boots, type: :numeric, default: 2
@@ -101,25 +155,51 @@ module Pinspec
101
155
  desc: "overwrite a pin file that has been hand-edited"
102
156
  method_option :snapshot, type: :string, default: "inline", enum: %w[inline insta approvals],
103
157
  desc: "snapshot backend"
104
- method_option :"app-env", type: :array, default: [], banner: "KEY=VALUE",
158
+ method_option :"app-env", type: :string, repeatable: true, banner: "KEY=VALUE",
105
159
  desc: "environment for the app's own runtime (when it is not this shell's Ruby)"
106
160
  method_option :sample, type: :boolean, default: false,
107
161
  desc: "read real rows through a generated read-only script in the app"
108
162
  method_option :"no-redact", type: :boolean, default: false,
109
163
  desc: "do NOT rewrite personal data in sampled rows (they land in a committed file)"
110
- def pin(target)
164
+ def pin(*args)
111
165
  guarded do
166
+ target = target_from(args)
112
167
  refuse_unbuilt_backend!
113
168
  warn_about_redaction!
169
+
170
+ return pin_directory(target) if File.directory?(target)
171
+
172
+ pin_one(target)
173
+ end
174
+ end
175
+
176
+ no_commands do
177
+ def pin_directory(path)
178
+ files = Batch.targets_in(path)
179
+ raise TargetNotFound, "no .rb files under #{path}" if files.empty?
180
+
181
+ puts "pinning #{files.size} file(s) under #{path}"
182
+ puts
183
+
184
+ report = Batch.new(files) { |file| pin_one(file, quiet: true) }.run
185
+
186
+ print_batch(report, path)
187
+
188
+ raise NothingStableToPin,
189
+ "nothing under #{path} could be pinned. Each refusal above says why." unless report.anything_pinned?
190
+ end
191
+
192
+ def pin_one(target, quiet: false)
114
193
  file, method = Analyzer::TargetParser.split_target(target)
115
194
 
116
195
  capture = Runner::Capture.new(
117
196
  app_root: options[:app], target: file, method: method,
118
- max_cases: options[:cases], boots: options[:boots], sandbox_env: app_env,
119
- sample: options[:sample], redact: !options[:"no-redact"]
197
+ max_cases: setting('cases', Inputs::Corpus::DEFAULT_MAX_CASES),
198
+ boots: setting('boots', 2), sandbox_env: app_env,
199
+ sample: setting('sample', false), redact: !options[:"no-redact"]
120
200
  ).run
121
201
 
122
- print_capture(capture)
202
+ print_capture(capture) unless quiet
123
203
  raise NothingStableToPin, nothing_stable_message(capture.stability) if capture.stability.nothing_to_pin?
124
204
 
125
205
  written = Emit::SpecWriter.new(
@@ -129,19 +209,27 @@ module Pinspec
129
209
  force: options[:force]
130
210
  ).write!
131
211
 
132
- puts
133
- print_written(written)
212
+ unless quiet
213
+ puts
214
+ print_written(written)
215
+ end
134
216
 
135
- return if options[:"skip-verify"]
217
+ if options[:"skip-verify"]
218
+ return Batch::Outcome.new(file: file, target: capture.target.qualified_name, status: :pinned,
219
+ detail: "not verified", pinned: written.pinned_cases.size,
220
+ spec_path: written.spec_path)
221
+ end
136
222
 
137
223
  outcomes = Verify::Verifier.new(
138
224
  app_root: options[:app], spec_path: written.spec_path,
139
- level: options[:"verify-level"].to_sym, env: app_env,
225
+ level: setting("verify-level", "full").to_sym, env: app_env,
140
226
  captured_tz: capture.plan.env_fingerprint[:tz]
141
227
  ).verify
142
228
 
143
- puts
144
- print_verification(outcomes)
229
+ unless quiet
230
+ puts
231
+ print_verification(outcomes)
232
+ end
145
233
 
146
234
  report_path = Report::Summary.new(
147
235
  app_root: options[:app], profile: Analyzer::AppProfileReader.read(options[:app]),
@@ -149,27 +237,34 @@ module Pinspec
149
237
  stability: capture.stability, written: written, outcomes: outcomes
150
238
  ).write!
151
239
 
152
- puts
153
- row "report", report_path
240
+ unless quiet
241
+ puts
242
+ row "report", report_path
243
+ end
154
244
 
155
245
  raise VerifyFailed, verify_failed_message(outcomes) unless outcomes.all?(&:green?)
246
+
247
+ Batch::Outcome.new(file: file, target: capture.target.qualified_name, status: :pinned,
248
+ detail: outcomes.map(&:config).join(", "), pinned: written.pinned_cases.size,
249
+ spec_path: written.spec_path)
156
250
  end
157
251
  end
158
252
 
159
- desc "validate FILE#METHOD", "Mutation-score a pin, one aspect at a time"
253
+ desc "validate TARGET", "Mutation-score a pin, one aspect at a time"
160
254
  method_option :app, type: :string, default: ".", desc: "target app root"
161
255
  method_option :cases, type: :numeric, default: Inputs::Corpus::DEFAULT_MAX_CASES
162
256
  method_option :"test-command", type: :string,
163
257
  desc: "run the app's suite in its own runtime (for apps on Ruby < 3.4)"
164
- method_option :"app-env", type: :array, default: [], banner: "KEY=VALUE",
258
+ method_option :"app-env", type: :string, repeatable: true, banner: "KEY=VALUE",
165
259
  desc: "environment for the app's own runtime"
166
- def validate(target)
260
+ def validate(*args)
167
261
  guarded do
262
+ target = target_from(args)
168
263
  file, method = Analyzer::TargetParser.split_target(target)
169
264
 
170
265
  capture = Runner::Capture.new(
171
266
  app_root: options[:app], target: file, method: method,
172
- max_cases: options[:cases], sandbox_env: app_env
267
+ max_cases: setting('cases', Inputs::Corpus::DEFAULT_MAX_CASES), sandbox_env: app_env
173
268
  ).run
174
269
 
175
270
  raise NothingStableToPin, nothing_stable_message(capture.stability) if capture.stability.nothing_to_pin?
@@ -180,7 +275,7 @@ module Pinspec
180
275
  app_root: options[:app], target: capture.target, plan: capture.plan,
181
276
  corpus: capture.corpus, stability: capture.stability,
182
277
  fk_map: profile.schema.fk_map,
183
- env: app_env, test_command: options[:"test-command"]
278
+ env: app_env, test_command: setting("test-command", nil)
184
279
  ).run
185
280
 
186
281
  print_scores(report)
@@ -214,11 +309,57 @@ module Pinspec
214
309
 
215
310
  private
216
311
 
312
+ # In 0.1.0 --app-env was a Thor array option, so `--app-env A=1 B=2 C=3` was the
313
+ # documented way to pass several. Making it repeatable fixed the option
314
+ # swallowing the target, but it would also have silently reinterpreted every
315
+ # existing invocation - the extra pairs would land here as positionals. So they
316
+ # are still understood, and the target is whichever positional is not a
317
+ # KEY=VALUE pair, wherever it sits on the line.
318
+ LEGACY_ENV_PAIR = /\A[A-Za-z_][A-Za-z0-9_]*=/
319
+
320
+ def target_from(args)
321
+ targets, pairs = args.partition { |arg| !arg.match?(LEGACY_ENV_PAIR) }
322
+ @legacy_env = pairs
323
+
324
+ if targets.empty?
325
+ raise TargetNotFound,
326
+ "no target given. Pass a file, a FILE#METHOD, or a directory: " \
327
+ "pinspec pin app/services/invoice_calculator.rb"
328
+ end
329
+
330
+ if targets.size > 1
331
+ raise AmbiguousTarget,
332
+ "more than one target given (#{targets.join(', ')}). pinspec pins one " \
333
+ "file, one FILE#METHOD, or one directory per run."
334
+ end
335
+
336
+ warn_about_legacy_env unless pairs.empty?
337
+ targets.first
338
+ end
339
+
340
+ def warn_about_legacy_env
341
+ warn "pinspec: `--app-env A=1 B=2` is the 0.1.0 form and still works. " \
342
+ "Prefer `--app-env A=1 --app-env B=2`, or record it once with `pinspec init`."
343
+ end
344
+
345
+ def config
346
+ @config ||= Config.load(options[:app] || ".")
347
+ end
348
+
349
+ # File first, then anything typed on the command line, so a flag always wins.
217
350
  def app_env
218
- Array(options[:"app-env"]).each_with_object({}) do |pair, out|
351
+ pairs = Array(@legacy_env) + Array(options[:"app-env"])
352
+
353
+ typed = pairs.each_with_object({}) do |pair, out|
219
354
  key, value = pair.split("=", 2)
220
355
  out[key] = value.to_s
221
356
  end
357
+
358
+ config.env.merge(typed)
359
+ end
360
+
361
+ def setting(key, default)
362
+ config.value(key, options[key.to_sym], default)
222
363
  end
223
364
 
224
365
  def refuse_unbuilt_backend!
@@ -300,17 +441,18 @@ module Pinspec
300
441
  stability = result.stability
301
442
 
302
443
  puts "capture #{result.target.qualified_name}"
303
- row "plan", "#{result.plan.plan_id} (isolation #{result.plan.isolation})"
304
- row "runs", "#{stability.runs} boots"
305
- row "cases", result.corpus.size
306
- row "stable", "#{stability.stable.size} of #{result.corpus.size}"
307
- row "compared", stability.compared_fields.join(", ")
308
- row "observations", result.output_path
309
-
310
- unless stability.stable.empty?
311
- puts
312
- puts " stable, and therefore pinnable:"
313
- stability.stable.each { |verdict| puts " #{verdict.case_id} #{summarize(verdict.observation)}" }
444
+ row "stable", "#{stability.stable.size} of #{result.corpus.size} cases, over #{stability.runs} boots"
445
+
446
+ if verbose?
447
+ row "plan", "#{result.plan.plan_id} (isolation #{result.plan.isolation})"
448
+ row "compared", stability.compared_fields.join(", ")
449
+ row "observations", result.output_path
450
+
451
+ unless stability.stable.empty?
452
+ puts
453
+ puts " stable, and therefore pinnable:"
454
+ stability.stable.each { |verdict| puts " #{verdict.case_id} #{summarize(verdict.observation)}" }
455
+ end
314
456
  end
315
457
 
316
458
  return if stability.unstable.empty?
@@ -346,10 +488,11 @@ module Pinspec
346
488
 
347
489
  def print_written(written)
348
490
  puts "emitted #{written.spec_path}"
349
- written.support_paths.each { |path| row "support", path }
350
- row "pinned", written.pinned_cases.join(", ")
351
- row "aspects", written.aspects.reject { |_, count| count.zero? }
352
- .map { |aspect, count| "#{count} #{aspect}" }.join(", ")
491
+ row "pinned", "#{written.pinned_cases.size} case(s): " +
492
+ written.aspects.reject { |_, count| count.zero? }
493
+ .map { |aspect, count| "#{count} #{aspect}" }.join(", ")
494
+
495
+ written.support_paths.each { |path| row "support", path } if verbose?
353
496
  end
354
497
 
355
498
  def print_verification(outcomes)
@@ -578,8 +721,32 @@ module Pinspec
578
721
  values.empty? ? "(none)" : values.join(", ")
579
722
  end
580
723
 
724
+ def verbose?
725
+ options[:verbose]
726
+ end
727
+
581
728
  def row(label, value)
582
729
  puts format(" %-14s %s", label, value)
583
730
  end
731
+
732
+ def print_batch(report, path)
733
+ width = report.outcomes.map { |o| File.basename(o.file).length }.max.to_i
734
+
735
+ report.outcomes.each do |outcome|
736
+ name = File.basename(outcome.file).ljust(width)
737
+
738
+ puts case outcome.status
739
+ when :pinned then format(" pinned %s %d case(s), verified", name, outcome.pinned)
740
+ when :refused then format(" skipped %s %s", name, outcome.detail)
741
+ else format(" FAILED %s %s", name, outcome.detail)
742
+ end
743
+ end
744
+
745
+ puts
746
+ row "pinned", "#{report.pinned.size} of #{report.outcomes.size}"
747
+ row "skipped", report.refused.size if report.refused.any?
748
+ row "failed", report.failed.size if report.failed.any?
749
+ row "specs", File.join(options[:app], Emit::SpecWriter::SPEC_DIR) if report.anything_pinned?
750
+ end
584
751
  end
585
752
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Pinspec
6
+ class Config
7
+ FILENAME = ".pinspec.yml"
8
+
9
+ KEYS = %w[cases boots sample redact compare-sql verify-level snapshot test-command env].freeze
10
+
11
+ EMPTY = { "env" => {} }.freeze
12
+
13
+ def self.load(app_root)
14
+ path = File.join(app_root.to_s, FILENAME)
15
+ return new(app_root, EMPTY, nil) unless File.file?(path)
16
+
17
+ parsed = YAML.safe_load(Analyzer::Source.read(path), permitted_classes: [], aliases: false) || {}
18
+ raise ConfigInvalid, "#{path} must contain a mapping, got #{parsed.class}" unless parsed.is_a?(Hash)
19
+
20
+ unknown = parsed.keys.map(&:to_s) - KEYS
21
+ unless unknown.empty?
22
+ raise ConfigInvalid,
23
+ "#{path} has unknown #{unknown.size == 1 ? 'key' : 'keys'} " \
24
+ "#{unknown.map(&:inspect).join(', ')}. Known keys: #{KEYS.join(', ')}."
25
+ end
26
+
27
+ new(app_root, parsed, path)
28
+ rescue Psych::SyntaxError => e
29
+ raise ConfigInvalid, "#{path} is not valid YAML: #{e.message}"
30
+ end
31
+
32
+ attr_reader :path
33
+
34
+ def initialize(app_root, data, path)
35
+ @app_root = app_root
36
+ @data = data || {}
37
+ @path = path
38
+ end
39
+
40
+ def exist?
41
+ !@path.nil?
42
+ end
43
+
44
+ def env
45
+ raw = @data["env"] || {}
46
+ raise ConfigInvalid, "#{@path}: `env` must be a mapping of KEY: VALUE" unless raw.is_a?(Hash)
47
+
48
+ raw.transform_keys(&:to_s).transform_values(&:to_s)
49
+ end
50
+
51
+ def [](key)
52
+ @data[key.to_s]
53
+ end
54
+
55
+ # CLI flag beats file beats default. Thor cannot tell a flag that was typed from
56
+ # one that defaulted, so the caller passes the default separately and an option
57
+ # equal to it is treated as untyped.
58
+ def value(key, given, default)
59
+ return given unless given == default || given.nil?
60
+
61
+ fetched = @data[key.to_s]
62
+ fetched.nil? ? default : fetched
63
+ end
64
+
65
+ def to_yaml_document
66
+ <<~YAML
67
+ # pinspec settings. Every key here is a CLI flag you would otherwise repeat;
68
+ # a flag on the command line still wins.
69
+ #
70
+ # The app's Ruby is detected on each run from .ruby-version or .tool-versions,
71
+ # so it is deliberately not recorded here. Use `env` only for what pinspec
72
+ # cannot work out for itself:
73
+ #
74
+ # env:
75
+ # DATABASE_USERNAME: myapp
76
+ #
77
+ #{YAML.dump(@data).sub(/\A---\n/, "").chomp}
78
+ YAML
79
+ end
80
+ end
81
+ end
@@ -79,6 +79,10 @@ module Pinspec
79
79
  self.exit_code = 11
80
80
  end
81
81
 
82
+ class ConfigInvalid < Error
83
+ self.exit_code = 13
84
+ end
85
+
82
86
  class PinspecInternalError < Error
83
87
  self.exit_code = 12
84
88
  end
@@ -70,9 +70,9 @@ module Pinspec
70
70
  end
71
71
 
72
72
  def environment
73
- Runner::Sandbox::SCRUBBED_ENV
74
- .merge("RAILS_ENV" => @rails_env, "DISABLE_SPRING" => "1", "TZ" => "UTC")
75
- .merge(@env)
73
+ Runner::Runtime.for(@app_root).env
74
+ .merge("RAILS_ENV" => @rails_env, "DISABLE_SPRING" => "1", "TZ" => "UTC")
75
+ .merge(@env)
76
76
  end
77
77
 
78
78
  def parse(stdout, stderr)
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinspec
4
+ module Runner
5
+ class Runtime
6
+ MANAGERS = %i[rvm rbenv asdf mise chruby].freeze
7
+
8
+ Result = Data.define(:env, :ruby_version, :manager, :note) do
9
+ def detected?
10
+ !manager.nil?
11
+ end
12
+
13
+ def same_ruby?
14
+ manager.nil? && note.nil?
15
+ end
16
+ end
17
+
18
+ BUNDLER_VARS = %w[
19
+ BUNDLE_GEMFILE BUNDLE_PATH BUNDLE_BIN_PATH BUNDLE_APP_CONFIG
20
+ BUNDLER_VERSION BUNDLER_SETUP RUBYOPT RUBYLIB
21
+ ].freeze
22
+
23
+ def self.for(app_root, home: Dir.home, current: RUBY_VERSION)
24
+ new(app_root, home: home, current: current).resolve
25
+ end
26
+
27
+ def initialize(app_root, home: Dir.home, current: RUBY_VERSION)
28
+ @app_root = app_root
29
+ @home = home
30
+ @current = current
31
+ end
32
+
33
+ def resolve
34
+ wanted = declared_version
35
+ return Result.new(env: scrub, ruby_version: @current, manager: nil, note: nil) if wanted.nil?
36
+ return Result.new(env: scrub, ruby_version: @current, manager: nil, note: nil) if satisfied_by_current?(wanted)
37
+
38
+ found = MANAGERS.filter_map { |manager| send(:"#{manager}_env", wanted)&.then { |e| [manager, e] } }.first
39
+
40
+ if found.nil?
41
+ return Result.new(
42
+ env: scrub, ruby_version: wanted, manager: nil,
43
+ note: "#{@app_root} declares Ruby #{wanted} and this is #{@current}, but no " \
44
+ "installation of #{wanted} was found under rvm, rbenv, asdf, mise or " \
45
+ "chruby. Pass the app's runtime explicitly:\n" \
46
+ " --app-env PATH=/path/to/ruby-#{wanted}/bin:$PATH"
47
+ )
48
+ end
49
+
50
+ manager, env = found
51
+ Result.new(env: scrub.merge(env), ruby_version: wanted, manager: manager, note: nil)
52
+ end
53
+
54
+ def declared_version
55
+ from_file(".ruby-version") || from_tool_versions
56
+ end
57
+
58
+ private
59
+
60
+ # Only bundler's variables are removed. GEM_HOME and GEM_PATH are left alone:
61
+ # dropping them is what forced every invocation to hand the app's gemset back
62
+ # by hand, and they are only wrong when the app runs on a different Ruby - in
63
+ # which case the detected runtime overwrites them anyway.
64
+ def scrub
65
+ BUNDLER_VARS.to_h { |name| [name, nil] }
66
+ end
67
+
68
+ def from_file(name)
69
+ path = File.join(@app_root, name)
70
+ return nil unless File.file?(path)
71
+
72
+ Analyzer::Source.read(path).strip[/\d+\.\d+(\.\d+)?/]
73
+ end
74
+
75
+ def from_tool_versions
76
+ path = File.join(@app_root, ".tool-versions")
77
+ return nil unless File.file?(path)
78
+
79
+ Analyzer::Source.read(path)[/^\s*ruby\s+(\S+)/, 1]&.[](/\d+\.\d+(\.\d+)?/)
80
+ end
81
+
82
+ # A patch-level difference is not worth relocating the runtime for: 3.3.0 and
83
+ # 3.3.6 run the same code. A minor difference is.
84
+ def satisfied_by_current?(wanted)
85
+ @current == wanted || series(@current) == series(wanted)
86
+ end
87
+
88
+ def series(version)
89
+ version.to_s.split(".").first(2).join(".")
90
+ end
91
+
92
+ def rvm_env(wanted)
93
+ root = File.join(@home, ".rvm")
94
+ ruby = File.join(root, "rubies", "ruby-#{wanted}", "bin")
95
+ return nil unless File.directory?(ruby)
96
+
97
+ gems = File.join(root, "gems", "ruby-#{wanted}")
98
+ env = { "PATH" => prepend(ruby) }
99
+ if File.directory?(gems)
100
+ env["GEM_HOME"] = gems
101
+ env["GEM_PATH"] = "#{gems}:#{gems}@global"
102
+ end
103
+ env
104
+ end
105
+
106
+ def rbenv_env(wanted)
107
+ bin = File.join(@home, ".rbenv", "versions", wanted, "bin")
108
+ File.directory?(bin) ? { "PATH" => prepend(bin), "RBENV_VERSION" => wanted } : nil
109
+ end
110
+
111
+ def asdf_env(wanted)
112
+ bin = File.join(@home, ".asdf", "installs", "ruby", wanted, "bin")
113
+ File.directory?(bin) ? { "PATH" => prepend(bin) } : nil
114
+ end
115
+
116
+ def mise_env(wanted)
117
+ bin = File.join(@home, ".local", "share", "mise", "installs", "ruby", wanted, "bin")
118
+ File.directory?(bin) ? { "PATH" => prepend(bin) } : nil
119
+ end
120
+
121
+ def chruby_env(wanted)
122
+ bin = File.join(@home, ".rubies", "ruby-#{wanted}", "bin")
123
+ File.directory?(bin) ? { "PATH" => prepend(bin) } : nil
124
+ end
125
+
126
+ def prepend(bin)
127
+ [bin, ENV.fetch("PATH", "")].reject(&:empty?).join(File::PATH_SEPARATOR)
128
+ end
129
+ end
130
+ end
131
+ end
@@ -8,7 +8,17 @@ module Pinspec
8
8
  module Runner
9
9
  class Sandbox
10
10
  PROBE_DIR = "tmp/pinspec"
11
- PROBE_FILE = "probe.rb"
11
+
12
+ # One probe file per run. A fixed path means two pinspec runs against the same
13
+ # application overwrite each other's probe between boots, and the result is not
14
+ # an error: the stability filter compares one target's observations against
15
+ # another's and reports the target as unstable, or crashes looking up a case id
16
+ # that belongs to a different corpus. Silent and wrong, which is worse than a
17
+ # lock. `pinspec pin app/services` is sequential, but a developer with two
18
+ # terminals or a CI that fans out is not.
19
+ def self.probe_filename(pid: Process.pid, object_id: nil)
20
+ "probe-#{pid}-#{object_id || rand(1 << 24)}.rb"
21
+ end
12
22
 
13
23
  DEFAULT_TIMEOUT = 600
14
24
 
@@ -18,18 +28,7 @@ module Pinspec
18
28
  "RAILS_ENV" => "test"
19
29
  }.freeze
20
30
 
21
- SCRUBBED_ENV = {
22
- "BUNDLE_GEMFILE" => nil,
23
- "BUNDLE_PATH" => nil,
24
- "BUNDLE_BIN_PATH" => nil,
25
- "BUNDLE_APP_CONFIG" => nil,
26
- "BUNDLER_VERSION" => nil,
27
- "BUNDLER_SETUP" => nil,
28
- "RUBYOPT" => nil,
29
- "RUBYLIB" => nil,
30
- "GEM_HOME" => nil,
31
- "GEM_PATH" => nil
32
- }.freeze
31
+ SCRUBBED_ENV = Runtime::BUNDLER_VARS.to_h { |name| [name, nil] }.freeze
33
32
 
34
33
  Result = Data.define(:run, :observations, :env, :plan_id, :stderr) do
35
34
  def observation(case_id)
@@ -46,7 +45,7 @@ module Pinspec
46
45
  end
47
46
 
48
47
  def probe_path
49
- File.join(@app_root, PROBE_DIR, PROBE_FILE)
48
+ @probe_path ||= File.join(@app_root, PROBE_DIR, self.class.probe_filename(object_id: object_id))
50
49
  end
51
50
 
52
51
  def write_probe!
@@ -58,16 +57,24 @@ module Pinspec
58
57
  def capture(boots: 2)
59
58
  write_probe!
60
59
 
61
- (1..boots).map do |run|
62
- execute(run: run, seed: 40 + run)
63
- end
60
+ results = (1..boots).map { |run| execute(run: run, seed: 40 + run) }
61
+
62
+ # Removed only on success. A probe left behind after a failure is the
63
+ # evidence; one left behind after every run just fills tmp/ with files a
64
+ # reader cannot tell apart.
65
+ FileUtils.rm_f(probe_path)
66
+ results
67
+ end
68
+
69
+ def runtime
70
+ @runtime ||= Runtime.for(@app_root)
64
71
  end
65
72
 
66
73
  private
67
74
 
68
75
  def execute(run:, seed:)
69
76
  command = runner_command
70
- env = SCRUBBED_ENV
77
+ env = runtime.env
71
78
  .merge(FORCED_ENV)
72
79
  .merge("PINSPEC_SHUFFLE_SEED" => seed.to_s)
73
80
  .merge(@env)
@@ -77,6 +84,7 @@ module Pinspec
77
84
  unless status.success?
78
85
  raise ProbeFailure,
79
86
  "the probe exited #{status.exitstatus} in #{@app_root}.\n" \
87
+ "#{runtime.note ? "#{runtime.note}\n\n" : ''}" \
80
88
  "#{stderr.to_s.lines.last(15).join}"
81
89
  end
82
90
 
@@ -108,7 +116,7 @@ module Pinspec
108
116
  def runner_command
109
117
  return @runner if @runner
110
118
 
111
- relative = File.join(PROBE_DIR, PROBE_FILE)
119
+ relative = File.join(PROBE_DIR, File.basename(probe_path))
112
120
 
113
121
  if File.file?(File.join(@app_root, "Gemfile"))
114
122
  ["bundle", "exec", "rails", "runner", relative]
@@ -90,9 +90,9 @@ module Pinspec
90
90
  end
91
91
 
92
92
  def environment
93
- Runner::Sandbox::SCRUBBED_ENV
94
- .merge("RAILS_ENV" => "test", "DISABLE_SPRING" => "1", "TZ" => @captured_tz)
95
- .merge(@env)
93
+ Runner::Runtime.for(@app_root).env
94
+ .merge("RAILS_ENV" => "test", "DISABLE_SPRING" => "1", "TZ" => @captured_tz)
95
+ .merge(@env)
96
96
  end
97
97
 
98
98
  def parse(stdout)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pinspec
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
 
6
6
  PROBE_VERSION = 3
7
7
  SERIALIZER_VERSION = 3
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pinspec
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rehan Munir
@@ -80,7 +80,9 @@ files:
80
80
  - lib/pinspec/analyzer/schema_reader.rb
81
81
  - lib/pinspec/analyzer/source.rb
82
82
  - lib/pinspec/analyzer/target_parser.rb
83
+ - lib/pinspec/batch.rb
83
84
  - lib/pinspec/cli.rb
85
+ - lib/pinspec/config.rb
84
86
  - lib/pinspec/emit/namer.rb
85
87
  - lib/pinspec/emit/spec_writer.rb
86
88
  - lib/pinspec/emit/stability_filter.rb
@@ -94,6 +96,7 @@ files:
94
96
  - lib/pinspec/report/summary.rb
95
97
  - lib/pinspec/runner/capture.rb
96
98
  - lib/pinspec/runner/probe_generator.rb
99
+ - lib/pinspec/runner/runtime.rb
97
100
  - lib/pinspec/runner/sandbox.rb
98
101
  - lib/pinspec/setup/context_builder.rb
99
102
  - lib/pinspec/setup/dependency_resolver.rb