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 +4 -4
- data/CHANGELOG.md +128 -0
- data/README.md +24 -3
- data/lib/pinspec/analyzer/discovery.rb +162 -0
- data/lib/pinspec/analyzer/factory_registry.rb +13 -4
- data/lib/pinspec/analyzer/target_parser.rb +266 -33
- data/lib/pinspec/cli.rb +143 -22
- data/lib/pinspec/config.rb +13 -2
- data/lib/pinspec/emit/spec_writer.rb +26 -22
- data/lib/pinspec/errors.rb +0 -4
- data/lib/pinspec/inputs/boundary.rb +2 -3
- data/lib/pinspec/inputs/sample_runner.rb +16 -3
- data/lib/pinspec/inputs/sampler.rb +0 -32
- data/lib/pinspec/report/summary.rb +30 -0
- data/lib/pinspec/runner/capture.rb +3 -1
- data/lib/pinspec/runner/probe_generator.rb +4 -1
- data/lib/pinspec/runner/sandbox.rb +19 -9
- data/lib/pinspec/setup/context_builder.rb +10 -7
- data/lib/pinspec/types.rb +2 -55
- data/lib/pinspec/verify/verifier.rb +37 -2
- data/lib/pinspec/version.rb +1 -1
- data/templates/factory_build.rb +10 -11
- metadata +2 -2
- data/lib/pinspec/emit/namer.rb +0 -103
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 18f3a4d549580bfcb2be37ffa3636d014b22d882e34c969454088406f3e9f162
|
|
4
|
+
data.tar.gz: 7c3079d9f32f69e8dfc62cf47374df80029cb0423a1662f98edf628c8c1f9e50
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 #
|
|
77
|
-
pinspec pin app/services/invoice_calculator.rb#total #
|
|
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
|
|
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:
|
|
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:
|
|
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) }
|