pinspec 0.1.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: 7f80ae1282baf0c4d24f694ced4a28e3333bd2db013a6b5a50c64dbf24349e51
4
- data.tar.gz: 3a970d963883ed690524562afa726ab0690aa6f5325bd3e65400c267b34ffb0f
3
+ metadata.gz: 18f3a4d549580bfcb2be37ffa3636d014b22d882e34c969454088406f3e9f162
4
+ data.tar.gz: 7c3079d9f32f69e8dfc62cf47374df80029cb0423a1662f98edf628c8c1f9e50
5
5
  SHA512:
6
- metadata.gz: e8d0773aa7d21161ba1a6b2c2cd64634dc0edeaca7bd9b089e92db7ecb76b9cc77e091316f5a97fa008b25cd790dd8e155777eede702138f14e08ebd9019d93f
7
- data.tar.gz: 76952dd81076d9e47aef99bbea8b181c6946312127a1360ea3ccd8ba7f0bbccfb25a511104a741292c920924e04803c35eb7d1f19e9187180954859f51430523
6
+ metadata.gz: f9371ec4351d1b66131026e04b847ca7bdc363006de97de5a8486d8aede22e29461ed11dac2ad69202a70b393e5ea1634c6ff63b33e24ed6cc8545afd0b52ed6
7
+ data.tar.gz: fdb55aad3d613a22b7d2956197ec916b9cdb0a93d59b1aa84a2620e4f0e00eaec4b64674bb178b553285fe0379ffbe8a75e00b510af0bd138d993da2c825bd17
data/CHANGELOG.md CHANGED
@@ -1,5 +1,211 @@
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
+
131
+ ## 0.2.0 - 2026-08-15
132
+
133
+ Ergonomics. 0.1.0 worked, but a run against a real application needed six lines of
134
+ environment before it would start, and almost none of it was intent.
135
+
136
+ ### The app's Ruby is detected, not declared
137
+
138
+ pinspec reads the target application's `.ruby-version` or `.tool-versions` and
139
+ locates that Ruby under rvm, rbenv, asdf, mise or chruby on every run. Only
140
+ bundler's variables are scrubbed from the child process now; `GEM_HOME` and
141
+ `GEM_PATH` are left alone, which is what previously forced every invocation to hand
142
+ the application's gemset back by hand. When the declared Ruby cannot be found,
143
+ the failure names the version, the one in use, and the exact `--app-env` line to
144
+ pass.
145
+
146
+ A patch-level difference is treated as the same runtime: 3.4.2 and 3.4.6 run the
147
+ same code, and relocating for it would fail on a machine that has only one.
148
+
149
+ ### `.pinspec.yml`
150
+
151
+ `pinspec init` writes it. It holds the flags you would otherwise repeat - `cases`,
152
+ `boots`, `sample`, `verify-level`, `test-command`, and an `env` mapping for what
153
+ pinspec cannot work out, such as database credentials. A flag on the command line
154
+ always wins. An unknown key is an error naming the key and listing the valid ones,
155
+ rather than silence that reads as "the setting does not work".
156
+
157
+ The detected runtime is deliberately **not** written to that file: recording one
158
+ machine's `PATH` bakes in a layout that goes stale the moment the app changes Ruby,
159
+ and detection re-runs anyway.
160
+
161
+ ### Targets
162
+
163
+ - A bare file assumes `#call`: `pinspec pin app/services/invoice_calculator.rb`.
164
+ - `--app-env` is repeatable (`--app-env A=1 --app-env B=2`) instead of an array
165
+ option, so the target can appear anywhere on the line. As an array option it
166
+ silently swallowed the positional target unless that came first.
167
+
168
+ **The 0.1.0 form keeps working.** `--app-env A=1 B=2 C=3` was what 0.1.0
169
+ documented, and changing the option type would have silently reinterpreted every
170
+ existing invocation rather than failing on it. Trailing `KEY=VALUE` pairs are
171
+ still collected, the target is whichever argument is not a pair, and using the old
172
+ form prints a note suggesting the new one. Nothing anyone scripted against 0.1.0
173
+ needs to change.
174
+ - **A directory pins everything under it.** Refusals are reported and the run
175
+ continues, since one target that takes a block should not end a run over forty.
176
+ The summary separates what was pinned, what was skipped and why, and what failed
177
+ verification. Exits non-zero when nothing pinned.
178
+
179
+ ### Output
180
+
181
+ Default output is the target, the stable count, what was pinned, the three verify
182
+ results and the report path. The plan id, compared fields and per-case detail move
183
+ behind `--verbose`. `pin` leads the command list, and `plan` and `capture` are
184
+ labelled diagnostics.
185
+
186
+ ### Compatibility
187
+
188
+ Nothing from 0.1.0 breaks. Both `--app-env` forms are accepted, `FILE#METHOD` still
189
+ works alongside the new bare-file shorthand, every flag keeps its name and meaning,
190
+ and `.pinspec.yml` is optional - an application without one behaves exactly as it
191
+ did, except that its Ruby is now found automatically.
192
+
193
+ ### Fixed
194
+
195
+ - **Two pinspec runs against the same application corrupted each other.** The probe
196
+ was written to a fixed `tmp/pinspec/probe.rb`, so a second run overwrote it between
197
+ the first run's two boots. The failure was silent rather than loud: the stability
198
+ filter compared one target's observations against another's and reported the target
199
+ as unstable, or crashed looking up a case id belonging to a different corpus. Each
200
+ run now writes its own probe, named after the process, and removes it on success -
201
+ keeping it after a failure, where it is the evidence. Found by running two captures
202
+ against one application at once.
203
+ - Batch discovery matched its skip patterns against the absolute path, so an
204
+ application living anywhere under a directory named `spec` had every file skipped.
205
+ Patterns now match the path relative to the directory being pinned.
206
+ - `analyze` did not read `.pinspec.yml`, so a typo in it stayed silent for the
207
+ command people run first.
208
+
3
209
  ## 0.1.0 - 2026-08-13
4
210
 
5
211
  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,65 @@ 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 verify SPEC_FILE` | Run **any** spec file in the three environments including one a human or an agent wrote. |
67
+ | `pinspec init` | Write `.pinspec.yml` so later runs need no flags. |
68
+ | `pinspec analyze` | App profile, schema, factories and hazards. Reads files only — no boot, no database. |
69
+ | `pinspec validate TARGET` | Mutation-scores the pin, one aspect at a time. Needs Ruby >= 3.4 and `mutineer`. |
70
+ | `pinspec report` | Prints the last run's markdown report. |
71
+ | `pinspec plan TARGET` | Diagnostic: the world it would build, without running anything. |
72
+ | `pinspec capture TARGET` | Diagnostic: run the probe only, and write `observations.json`. |
52
73
 
53
- Useful flags: `--cases N`, `--boots N`, `--sample` (read real rows from your
54
- development database), `--no-redact`, `--force`, `--app-env KEY=VALUE`.
74
+ `TARGET` is a file, a `FILE#METHOD`, or a directory:
75
+
76
+ ```bash
77
+ pinspec pin app/services/invoice_calculator.rb # discovers the method
78
+ pinspec pin app/services/invoice_calculator.rb#total # or name it yourself
79
+ pinspec pin app/services # everything under it
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.
55
88
 
56
- The target comes first. `--app-env` is an array option and will swallow it otherwise.
89
+ Pinning a directory keeps going when a target is refused, and prints one summary:
90
+
91
+ ```
92
+ pinned invoice_calculator.rb 2 case(s), verified
93
+ skipped report_builder.rb BlockRequired
94
+ pinned status_reporter.rb 2 case(s), verified
95
+
96
+ pinned 2 of 3
97
+ skipped 1
98
+ ```
99
+
100
+ Useful flags: `--cases N`, `--boots N`, `--sample` (read real rows from your
101
+ development database), `--no-redact`, `--force`, `--verbose`, and `--app-env KEY=VALUE`
102
+ for the rare case where the runtime cannot be detected. `--app-env` is repeatable;
103
+ the older `--app-env A=1 B=2` form is still accepted. Anything you find yourself
104
+ repeating belongs in `.pinspec.yml`.
57
105
 
58
106
  ## Verification
59
107
 
@@ -62,13 +110,26 @@ captured it proves repeatability rather than portability:
62
110
 
63
111
  - **isolated** — the file alone, as captured.
64
112
  - **hostile** — a different timezone, locale and RSpec seed.
65
- - **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.
66
114
 
67
115
  The emitted spec forces the capture's answer on every axis rather than inheriting
68
116
  the suite's: isolation regime, queue adapter, clock, seed, locale and zone. A suite
69
117
  that truncates instead of transacting, or that runs jobs inline, would otherwise
70
118
  turn a green capture into a red or vacuous spec.
71
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
+
72
133
  ## No database ids in a pin
73
134
 
74
135
  Postgres sequences are not transactional, so a rolled-back case still advances them
@@ -112,6 +173,7 @@ sources are hashed, so a committed spec does not map back to production rows.
112
173
  | `db/structure.sql` instead of `db/schema.rb` | `SchemaFormatUnsupported` | 6 |
113
174
  | Rails below 6.0 | `UnsupportedRailsVersion` | 10 |
114
175
  | no case was stable across boots | `NothingStableToPin` | 8 |
176
+ | `.pinspec.yml` has an unknown key or is not valid YAML | `ConfigInvalid` | 13 |
115
177
 
116
178
  It will not pass `nil` for a model it could not build: the target would raise on nil,
117
179
  and that error would be pinned as though your application produced it.
@@ -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) }