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 +4 -4
- data/CHANGELOG.md +78 -0
- data/README.md +58 -17
- data/lib/pinspec/analyzer/target_parser.rb +9 -4
- data/lib/pinspec/batch.rb +80 -0
- data/lib/pinspec/cli.rb +211 -44
- data/lib/pinspec/config.rb +81 -0
- data/lib/pinspec/errors.rb +4 -0
- data/lib/pinspec/inputs/sample_runner.rb +3 -3
- data/lib/pinspec/runner/runtime.rb +131 -0
- data/lib/pinspec/runner/sandbox.rb +27 -19
- data/lib/pinspec/verify/verifier.rb +3 -3
- data/lib/pinspec/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3725fabfaf5ca20380e8006be8da9324aa2e1f044dba71ee7cd867360a42fe57
|
|
4
|
+
data.tar.gz: 4d83085aaa24d6b1eb97577f32fdc8bb4696c28469bacfab8187c04cd12f3e5a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
11
|
+
cd myapp
|
|
12
|
+
pinspec pin app/services/invoice_calculator.rb
|
|
12
13
|
```
|
|
13
14
|
|
|
14
15
|
```
|
|
15
16
|
capture InvoiceCalculator#call
|
|
16
|
-
|
|
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
|
|
21
|
-
aspects 2 return, 2 jobs
|
|
20
|
+
pinned 3 case(s): 3 return, 3 jobs
|
|
22
21
|
|
|
23
22
|
verify
|
|
24
|
-
isolated green (
|
|
25
|
-
hostile green (
|
|
26
|
-
neighbored green (
|
|
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
|
|
47
|
-
| `pinspec
|
|
48
|
-
| `pinspec
|
|
49
|
-
| `pinspec
|
|
50
|
-
| `pinspec
|
|
51
|
-
| `pinspec
|
|
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
|
-
|
|
54
|
-
development database), `--no-redact`, `--force`, `--app-env KEY=VALUE`.
|
|
73
|
+
`TARGET` is a file, a `FILE#METHOD`, or a directory:
|
|
55
74
|
|
|
56
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 "
|
|
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:
|
|
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
|
|
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: :
|
|
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(
|
|
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:
|
|
81
|
-
boots:
|
|
82
|
-
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:
|
|
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
|
|
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: :
|
|
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(
|
|
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:
|
|
119
|
-
|
|
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
|
-
|
|
133
|
-
|
|
212
|
+
unless quiet
|
|
213
|
+
puts
|
|
214
|
+
print_written(written)
|
|
215
|
+
end
|
|
134
216
|
|
|
135
|
-
|
|
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:
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
|
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: :
|
|
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(
|
|
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:
|
|
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:
|
|
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"])
|
|
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 "
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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.
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
data/lib/pinspec/errors.rb
CHANGED
|
@@ -70,9 +70,9 @@ module Pinspec
|
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
def environment
|
|
73
|
-
Runner::
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
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 =
|
|
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,
|
|
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::
|
|
94
|
-
|
|
95
|
-
|
|
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)
|
data/lib/pinspec/version.rb
CHANGED
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.
|
|
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
|