pinspec 0.2.0 → 0.3.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: 3725fabfaf5ca20380e8006be8da9324aa2e1f044dba71ee7cd867360a42fe57
4
- data.tar.gz: 4d83085aaa24d6b1eb97577f32fdc8bb4696c28469bacfab8187c04cd12f3e5a
3
+ metadata.gz: 18f3a4d549580bfcb2be37ffa3636d014b22d882e34c969454088406f3e9f162
4
+ data.tar.gz: 7c3079d9f32f69e8dfc62cf47374df80029cb0423a1662f98edf628c8c1f9e50
5
5
  SHA512:
6
- metadata.gz: 62faf9d5c24304fe2d122d05ddf17634185728764e4a59dd5bdbba8c953e718d748497abd562d95e77ed35c8dc4b1477a1bc0732fb403498f846498f3572cbd3
7
- data.tar.gz: 5af0cf46d7730057402c1b3dcc0679ec495be239b94fba0aecae7e21a1029fdedfa15f053404f7f52e5594e9706522820cac109fe68abf9de24f7da323f9a121
6
+ metadata.gz: f9371ec4351d1b66131026e04b847ca7bdc363006de97de5a8486d8aede22e29461ed11dac2ad69202a70b393e5ea1634c6ff63b33e24ed6cc8545afd0b52ed6
7
+ data.tar.gz: fdb55aad3d613a22b7d2956197ec916b9cdb0a93d59b1aa84a2620e4f0e00eaec4b64674bb178b553285fe0379ffbe8a75e00b510af0bd138d993da2c825bd17
data/CHANGELOG.md CHANGED
@@ -1,5 +1,133 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 - 2026-08-15
4
+
5
+ More of a real codebase reachable, four things that were quietly wrong put right, and
6
+ 294 fewer lines of code.
7
+
8
+ Measured across five public Rails applications (openfoodnetwork, chatwoot, mastodon,
9
+ forem, publishing-api — 841 service files), the share pinspec can plan a world for:
10
+
11
+ | | before | after |
12
+ |---|---|---|
13
+ | openfoodnetwork | 16% | 39% |
14
+ | chatwoot | 2% | 45% |
15
+ | mastodon | 10% | 76% |
16
+ | forem | 23% | 57% |
17
+ | publishing-api | 24% | 67% |
18
+ | **all** | **14%** | **53%** |
19
+
20
+ ### Reaching more targets
21
+
22
+ - **The target method is discovered, not assumed.** `#call` is a convention, not the
23
+ convention — chatwoot's entry points are named `perform`. pinspec counts the method
24
+ names a directory actually uses and follows that application's own convention,
25
+ falling back to `call`/`perform`/`run`/`execute`/`process`, then a class's only
26
+ public method. Several plausible methods and no conventional one is a question, not
27
+ a guess.
28
+ - **`def self.call(...)` delegating to `def call`** is the commonest service idiom in
29
+ Ruby, and it made the bare name resolve to two definitions — 108 of forem's 322
30
+ files were refused for it. The instance method is now named explicitly.
31
+ - **A class with no `initialize` has the default constructor, not an unreadable one.**
32
+ Superclasses are resolved across the application's own files; only a constructor
33
+ that exists and cannot be read is still refused. That one change took mastodon from
34
+ 10% to 76%.
35
+ - **Two type-hint rules.** In 222 of 222 parameters refused as unbuildable models, the
36
+ "type" was just the parameter's own name capitalised — the refusal was rejecting
37
+ pinspec's own guess. Names shaped like `create_params` or `options` are Hashes and
38
+ plural names are Arrays, which are values pinspec can actually build.
39
+
40
+ ### `pinspec verify SPEC_FILE`
41
+
42
+ Runs **any** spec file in the three environments, whoever wrote it. The Verifier never
43
+ knew where a spec came from, so this needed a command and nothing else. A spec
44
+ asserting `Time.now.strftime("%z")` passes where it was written and fails under
45
+ `hostile` — the failure a colleague's CI would have reported a week later.
46
+
47
+ ### Fixed
48
+
49
+ - **`neighbored` never ran anything twice.** It passed the same path to RSpec twice,
50
+ and RSpec loads a given path once — so the configuration silently re-checked what
51
+ `isolated` checks, while the README claimed it caught accumulated state. It now runs
52
+ a copy alongside the original, which really does execute the pin twice. On its first
53
+ honest run it immediately found a real defect: **factory_bot sequences are
54
+ process-global**, so a pin whose world uses one produced `INV-1` then `INV-2` and
55
+ failed against its own snapshot. Both hosts now rewind sequences before every case.
56
+ - **`--boots 1` silently defeated the central claim.** One run has nothing to compare
57
+ against, so every case was called stable by virtue of never being checked. Two is
58
+ now a floor, not a default.
59
+ - **The emitted spec dropped a factory record's attributes and associations.** The
60
+ probe passed them; the spec did not, so the two hosts built different worlds.
61
+ - **`--sample` bound to the factory record, not the sampled row.** Imports are now
62
+ built first, so the real row the flag exists to fetch is the one the target receives.
63
+ - **`redact:` in `.pinspec.yml` was accepted and ignored.**
64
+
65
+ ### Two constructor shapes real applications use
66
+
67
+ - **attr_extras.** `pattr_initialize`/`attr_initialize` generate the constructor, so
68
+ there is no `def initialize` to read and pinspec concluded the class took no
69
+ arguments - then called `new` with none, and the generated constructor raised
70
+ KeyError inside the probe. That is **110 of chatwoot's 386 service files**. Both
71
+ forms are now read: bare symbols are positional, symbols inside an array are
72
+ keywords, and attr_extras' trailing `!` marks the required ones.
73
+ - **A module that answers its own methods.** `module_function` and `extend self` mean
74
+ there is nothing to construct, but pinspec called `.new` on the module and the probe
75
+ died with `undefined method 'new' for module ...` - an error naming the application
76
+ rather than the shape pinspec had failed to recognise.
77
+
78
+ Both were found by booting real applications rather than by reading fixtures.
79
+
80
+ One consequence is worth stating plainly: chatwoot's plannable share **fell** from
81
+ 109 files to 97. Those twelve were planning successfully only because pinspec could
82
+ not see their constructor at all; they would have failed inside the probe. Refusing
83
+ them up front, with the parameter named, is the honest answer - `channel` alone
84
+ accounts for 12, and it is genuinely unbuildable because chatwoot's channels are
85
+ separate models with separate tables.
86
+
87
+ ### Upgrading from 0.1.0 or 0.2.0
88
+
89
+ Nothing you already have stops working. Verified by generating pins with the real
90
+ 0.1.0 and 0.2.0 and putting them through this version:
91
+
92
+ - **Pins written by 0.1.0 and 0.2.0 still verify green**, unchanged, including under
93
+ the new honest `neighbored`.
94
+ - **Re-pinning one keeps its method and its filename.** Discovery follows the
95
+ application's convention, which can differ from the `#call` older versions always
96
+ assumed; where a pin already exists, the method it froze wins, so re-running cannot
97
+ quietly write a second file and leave the first one in the suite.
98
+ - **Every flag those versions accepted is still accepted**, including `--skip-verify`
99
+ and the `--app-env A=1 B=2` array form. `--snapshot` is taken and ignored with a
100
+ note rather than rejected - it is no longer advertised, because a flag that does
101
+ nothing should not invite use.
102
+ - **A `.pinspec.yml` naming a retired key warns and continues** instead of failing the
103
+ build. A key that was never valid is still an error.
104
+ - **Exit codes mean what they always meant.** 11 (`EnvironmentRefused`) is retired and
105
+ deliberately not reused, so a script testing for it simply never sees it.
106
+
107
+ `spec/upgrade_compatibility_spec.rb` holds all of this, so it cannot regress quietly.
108
+
109
+ ### Deleted
110
+
111
+ 294 lines net. Nothing here changes behaviour any user could observe:
112
+
113
+ - `Emit::Namer` (253 lines with its spec) — an LLM-backed description generator with
114
+ an injectable client port, a prompt builder and an output sanitiser, reachable from
115
+ nothing. There was no flag that turned it on.
116
+ - `--snapshot` — a flag whose entire implementation refused two of its own three
117
+ values. Removed rather than given an abstraction to hang implementations on.
118
+ - `Sampler.choose_env` / `production_like?` / `guard_production!` and
119
+ `EnvironmentRefused` — a guard for a path with no caller.
120
+ - `FactoryIndex#ancestry` / `#traits_for` / `#attributes_for` — one concept
121
+ implemented twice; this was the copy nothing called.
122
+ - `Sandbox`'s `timeout:` and `runner:` seams (there is no timeout wrapper anywhere),
123
+ `MAX_GENERATIONS`, and 31 of 41 `NON_MODEL_HINTS` entries that fire zero times.
124
+
125
+ ### Added
126
+
127
+ - A **vacuous-pin caveat** in the report: a case that returned nothing, wrote no rows,
128
+ enqueued nothing and sent nothing did run, but almost any change to the target would
129
+ still satisfy it. These are exactly the pins that score weak.
130
+
3
131
  ## 0.2.0 - 2026-08-15
4
132
 
5
133
  Ergonomics. 0.1.0 worked, but a run against a real application needed six lines of
data/README.md CHANGED
@@ -63,6 +63,7 @@ on the command line always wins.
63
63
  | Command | What it does |
64
64
  | --- | --- |
65
65
  | `pinspec pin TARGET` | Capture, emit the spec, verify it. This is the one you want. |
66
+ | `pinspec verify SPEC_FILE` | Run **any** spec file in the three environments — including one a human or an agent wrote. |
66
67
  | `pinspec init` | Write `.pinspec.yml` so later runs need no flags. |
67
68
  | `pinspec analyze` | App profile, schema, factories and hazards. Reads files only — no boot, no database. |
68
69
  | `pinspec validate TARGET` | Mutation-scores the pin, one aspect at a time. Needs Ruby >= 3.4 and `mutineer`. |
@@ -73,11 +74,18 @@ on the command line always wins.
73
74
  `TARGET` is a file, a `FILE#METHOD`, or a directory:
74
75
 
75
76
  ```bash
76
- pinspec pin app/services/invoice_calculator.rb # assumes #call
77
- pinspec pin app/services/invoice_calculator.rb#total # a different method
77
+ pinspec pin app/services/invoice_calculator.rb # discovers the method
78
+ pinspec pin app/services/invoice_calculator.rb#total # or name it yourself
78
79
  pinspec pin app/services # everything under it
79
80
  ```
80
81
 
82
+ When you do not name a method, pinspec finds one: it counts the method names the
83
+ directory actually uses and follows that convention, so an application whose entry
84
+ points are `perform` needs no configuration. Failing that it looks for
85
+ `call`, `perform`, `run`, `execute`, `process`, then a class's only public method.
86
+ When several public methods are plausible and none is conventional it **asks**
87
+ rather than picking, listing what it found. `--method NAME` settles it.
88
+
81
89
  Pinning a directory keeps going when a target is refused, and prints one summary:
82
90
 
83
91
  ```
@@ -102,13 +110,26 @@ captured it proves repeatability rather than portability:
102
110
 
103
111
  - **isolated** — the file alone, as captured.
104
112
  - **hostile** — a different timezone, locale and RSpec seed.
105
- - **neighbored** — the file twice in one process, so accumulated state shows up.
113
+ - **neighbored** — a copy of the file alongside it, so state left behind by one run shows up in the next.
106
114
 
107
115
  The emitted spec forces the capture's answer on every axis rather than inheriting
108
116
  the suite's: isolation regime, queue adapter, clock, seed, locale and zone. A suite
109
117
  that truncates instead of transacting, or that runs jobs inline, would otherwise
110
118
  turn a green capture into a red or vacuous spec.
111
119
 
120
+ ## Verifying specs pinspec did not write
121
+
122
+ Everyone has an agent that writes tests now. Almost nobody runs them anywhere but the
123
+ machine that wrote them.
124
+
125
+ ```bash
126
+ pinspec verify spec/models/order_spec.rb
127
+ ```
128
+
129
+ Same three environments, on any RSpec file. A spec asserting `Time.now.strftime("%z")`
130
+ passes where it was written and fails under `hostile` — which is what a colleague's CI
131
+ in another timezone would have told you a week later.
132
+
112
133
  ## No database ids in a pin
113
134
 
114
135
  Postgres sequences are not transactional, so a rolled-back case still advances them
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Pinspec
6
+ module Analyzer
7
+ class Discovery
8
+ CONVENTIONAL = %w[call perform run execute process].freeze
9
+
10
+ # Never a target: these are object protocol, not behaviour anyone pins.
11
+ NON_TARGETS = %w[initialize to_s to_str inspect hash eql? == <=> to_proc].freeze
12
+
13
+ # Conversion methods, which are USUALLY protocol but are sometimes the whole
14
+ # public surface of a service object - OFN's AvailablePaymentMethodsService
15
+ # exposes exactly `to_a` and nothing else. Excluding them outright meant such a
16
+ # class had no candidates at all and was refused as ambiguous. They are ranked
17
+ # last instead, so they win only when nothing else is offered.
18
+ LAST_RESORT = %w[to_a to_h each].freeze
19
+
20
+ Choice = Data.define(:method_name, :reason, :candidates, :owner) do
21
+ def ambiguous?
22
+ method_name.nil?
23
+ end
24
+
25
+ # `def self.call(...)` delegating to `def call` is the commonest service-object
26
+ # idiom in Ruby, and it makes the bare name resolve to two definitions. The
27
+ # instance method is the real entry point, so it is named explicitly rather
28
+ # than left to look ambiguous.
29
+ def descriptor
30
+ owner ? "#{owner}##{method_name}" : method_name
31
+ end
32
+ end
33
+
34
+ # `convention` is the method name this application uses most, counted once over
35
+ # the directory being pinned. Hardcoding `call` reads 2% of a codebase whose
36
+ # services are named `perform`.
37
+ def self.convention_for(files)
38
+ counts = Hash.new(0)
39
+
40
+ files.each do |file|
41
+ surface = new(file).surface
42
+ surface[:instance].each { |name| counts[name] += 1 }
43
+ end
44
+
45
+ best = counts.max_by { |name, count| [count, CONVENTIONAL.index(name) ? 1 : 0] }
46
+ return nil if best.nil? || best.last < 2
47
+
48
+ best.first
49
+ end
50
+
51
+ def initialize(file_path)
52
+ @file_path = file_path
53
+ end
54
+
55
+ def surface
56
+ @surface ||= read_surface
57
+ end
58
+
59
+ def choose(convention: nil)
60
+ instance = surface[:instance]
61
+ singleton = surface[:singleton]
62
+
63
+ if instance.empty? && singleton.empty?
64
+ return Choice.new(method_name: nil, reason: :no_public_methods, candidates: [], owner: nil)
65
+ end
66
+
67
+ preferred = [convention].compact + CONVENTIONAL
68
+
69
+ preferred.each do |name|
70
+ return chosen(name, reason_for(name, convention), instance) if instance.include?(name)
71
+ end
72
+
73
+ preferred.each do |name|
74
+ return Choice.new(method_name: name, reason: :class_method, candidates: singleton, owner: nil) if singleton.include?(name)
75
+ end
76
+
77
+ # A class with exactly one public method has only one thing it can mean.
78
+ return chosen(instance.first, :sole_method, instance) if instance.size == 1
79
+
80
+ # Nothing conventional, and what remains is a single conversion method: that is
81
+ # this class's whole public surface, so it is the target.
82
+ # Conversion methods rank last, so a real entry point wins - but a class whose
83
+ # only ordinary method is one of them still has something to pin.
84
+ ordinary = instance - LAST_RESORT
85
+ return chosen(ordinary.first, :sole_method, instance) if ordinary.size == 1
86
+ return Choice.new(method_name: singleton.first, reason: :sole_method, candidates: singleton, owner: nil) if instance.empty? && singleton.size == 1
87
+
88
+ Choice.new(method_name: nil, reason: :ambiguous, candidates: (instance + singleton).first(8), owner: nil)
89
+ end
90
+
91
+ private
92
+
93
+ # Qualified only when the same name also exists as a class method, which is
94
+ # what makes the bare name look like two definitions.
95
+ def chosen(name, reason, candidates)
96
+ doubled = surface[:singleton].include?(name)
97
+
98
+ Choice.new(method_name: name, reason: reason, candidates: candidates,
99
+ owner: doubled ? surface[:owners][name] : nil)
100
+ end
101
+
102
+ def reason_for(name, convention)
103
+ name == convention ? :convention : :conventional_name
104
+ end
105
+
106
+ def read_surface
107
+ source = Source.read(@file_path)
108
+ result = Prism.parse(source)
109
+ return { instance: [], singleton: [], owners: {} } unless result.success?
110
+
111
+ instance = []
112
+ singleton = []
113
+ owners = {}
114
+ visibility = :public
115
+ class_stack = []
116
+
117
+ walk = lambda do |node, in_singleton|
118
+ return if node.nil?
119
+
120
+ case node
121
+ when Prism::CallNode
122
+ # `private` on its own switches visibility; `private :foo` names one method.
123
+ visibility = node.name if %i[private protected public].include?(node.name) && node.arguments.nil?
124
+ when Prism::ClassNode
125
+ visibility = :public
126
+ class_stack.push(node.constant_path.slice)
127
+ node.compact_child_nodes.each { |child| walk.call(child, in_singleton) }
128
+ class_stack.pop
129
+ return
130
+ when Prism::ModuleNode
131
+ visibility = :public
132
+ when Prism::SingletonClassNode
133
+ node.compact_child_nodes.each { |child| walk.call(child, true) }
134
+ return
135
+ when Prism::DefNode
136
+ collect(node, in_singleton, visibility, instance, singleton)
137
+ owners[node.name.to_s] ||= class_stack.last unless node.receiver || in_singleton
138
+ end
139
+
140
+ node.compact_child_nodes.each { |child| walk.call(child, in_singleton) }
141
+ end
142
+
143
+ walk.call(result.value, false)
144
+
145
+ { instance: instance.uniq, singleton: singleton.uniq, owners: owners }
146
+ end
147
+
148
+ def collect(node, in_singleton, visibility, instance, singleton)
149
+ name = node.name.to_s
150
+ return unless visibility == :public
151
+ return if NON_TARGETS.include?(name)
152
+ return if name.end_with?("=")
153
+
154
+ if node.receiver || in_singleton
155
+ singleton << name
156
+ else
157
+ instance << name
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -200,7 +200,7 @@ module Pinspec
200
200
  name = decode(args.first)&.to_sym
201
201
  return nil unless name
202
202
 
203
- attribute(name, :association, node, factory: keyword_options(args)[:factory]&.to_sym, source: nil)
203
+ attribute(name, :association, node, factory: factory_name(args), source: nil)
204
204
  when :sequence
205
205
  name = decode(args.first)&.to_sym
206
206
  return nil unless name
@@ -216,17 +216,26 @@ module Pinspec
216
216
  return nil if BARE_NON_ATTRIBUTES.include?(node.name)
217
217
  return nil if STRUCTURAL_CALLS.include?(node.name)
218
218
 
219
- if node.block
219
+ if node.block.is_a?(Prism::BlockNode)
220
220
  attribute(node.name, :block, node)
221
221
  elsif args.empty?
222
222
  attribute(node.name, :association, node)
223
223
  elsif args.all? { |arg| arg.is_a?(Prism::KeywordHashNode) }
224
- attribute(node.name, :association, node, factory: keyword_options(args)[:factory]&.to_sym)
224
+ attribute(node.name, :association, node, factory: factory_name(args))
225
225
  else
226
226
  attribute(node.name, :static, node)
227
227
  end
228
228
  end
229
229
 
230
+ # factory_bot accepts an array here - `factory: %i[user admin]` names a factory
231
+ # and then traits to apply to it - so the value is not always a symbol.
232
+ def factory_name(args)
233
+ declared = keyword_options(args)[:factory]
234
+ declared = declared.first if declared.is_a?(Array)
235
+
236
+ declared&.to_sym
237
+ end
238
+
230
239
  def attribute(name, kind, node, factory: nil, source: :from_node)
231
240
  FactoryAttribute.new(
232
241
  name: name.to_sym,
@@ -238,7 +247,7 @@ module Pinspec
238
247
  end
239
248
 
240
249
  def attribute_source(node)
241
- if node.block
250
+ if node.block.is_a?(Prism::BlockNode)
242
251
  node.block.body&.slice
243
252
  else
244
253
  args = Array(node.arguments&.arguments).reject { |a| a.is_a?(Prism::KeywordHashNode) }