pinspec 0.1.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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +133 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +183 -0
  5. data/exe/pinspec +8 -0
  6. data/lib/pinspec/analyzer/app_profile_reader.rb +348 -0
  7. data/lib/pinspec/analyzer/factory_registry.rb +288 -0
  8. data/lib/pinspec/analyzer/inflector.rb +85 -0
  9. data/lib/pinspec/analyzer/schema_reader.rb +444 -0
  10. data/lib/pinspec/analyzer/source.rb +56 -0
  11. data/lib/pinspec/analyzer/target_parser.rb +710 -0
  12. data/lib/pinspec/cli.rb +585 -0
  13. data/lib/pinspec/emit/namer.rb +103 -0
  14. data/lib/pinspec/emit/spec_writer.rb +504 -0
  15. data/lib/pinspec/emit/stability_filter.rb +183 -0
  16. data/lib/pinspec/errors.rb +85 -0
  17. data/lib/pinspec/inputs/boundary.rb +112 -0
  18. data/lib/pinspec/inputs/corpus.rb +148 -0
  19. data/lib/pinspec/inputs/hydrator.rb +197 -0
  20. data/lib/pinspec/inputs/redactor.rb +138 -0
  21. data/lib/pinspec/inputs/sample_runner.rb +98 -0
  22. data/lib/pinspec/inputs/sampler.rb +187 -0
  23. data/lib/pinspec/report/summary.rb +348 -0
  24. data/lib/pinspec/runner/capture.rb +127 -0
  25. data/lib/pinspec/runner/probe_generator.rb +662 -0
  26. data/lib/pinspec/runner/sandbox.rb +121 -0
  27. data/lib/pinspec/setup/context_builder.rb +471 -0
  28. data/lib/pinspec/setup/dependency_resolver.rb +236 -0
  29. data/lib/pinspec/tags.rb +103 -0
  30. data/lib/pinspec/types.rb +497 -0
  31. data/lib/pinspec/validate/mutation_adapter.rb +108 -0
  32. data/lib/pinspec/validate/pin_scorer.rb +164 -0
  33. data/lib/pinspec/verify/verifier.rb +149 -0
  34. data/lib/pinspec/version.rb +8 -0
  35. data/lib/pinspec.rb +17 -0
  36. data/templates/factory_build.rb +54 -0
  37. data/templates/serializer.rb +243 -0
  38. data/templates/spec_support.rb +83 -0
  39. metadata +134 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7f80ae1282baf0c4d24f694ced4a28e3333bd2db013a6b5a50c64dbf24349e51
4
+ data.tar.gz: 3a970d963883ed690524562afa726ab0690aa6f5325bd3e65400c267b34ffb0f
5
+ SHA512:
6
+ metadata.gz: e8d0773aa7d21161ba1a6b2c2cd64634dc0edeaca7bd9b089e92db7ecb76b9cc77e091316f5a97fa008b25cd790dd8e155777eede702138f14e08ebd9019d93f
7
+ data.tar.gz: 76952dd81076d9e47aef99bbea8b181c6946312127a1360ea3ccd8ba7f0bbccfb25a511104a741292c920924e04803c35eb7d1f19e9187180954859f51430523
data/CHANGELOG.md ADDED
@@ -0,0 +1,133 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-08-13
4
+
5
+ First release.
6
+
7
+ `pinspec pin FILE#METHOD --app PATH` resolves a Rails service object or model
8
+ method, builds the world it needs, runs it inside the application, writes an RSpec
9
+ file that freezes what it observed, and verifies that file in the application's own
10
+ test environment.
11
+
12
+ ### Commands
13
+
14
+ - `analyze` - application profile, schema, factories and hazards. Static: no boot,
15
+ no database connection.
16
+ - `plan` - the world pinspec would build and the arguments it would pass. Pure data,
17
+ readable before anything runs.
18
+ - `capture` - runs the generated probe in the application and writes
19
+ `observations.json`.
20
+ - `pin` - capture, emit the spec, verify it.
21
+ - `validate` - mutation-scores a pin one aspect at a time. Requires Ruby >= 3.4 and
22
+ `mutineer`; refuses with an explanation otherwise.
23
+ - `report` - the markdown report from the last run.
24
+
25
+ ### Analysis
26
+
27
+ - Targets resolve from a static parse across six construction shapes: `new`, class
28
+ method, interactor, `dry_initializer`, `Struct` and model instance, including one
29
+ level of superclass inheritance. Constructor arguments are part of the invocation
30
+ surface, so a zero-argument `#call` whose dependencies arrive via `initialize` is
31
+ supported.
32
+ - `db/schema.rb` is parsed into tables, columns, indexes and a foreign-key map with
33
+ provenance: a database constraint, a declared association, or an inferred `*_id`
34
+ column. Constraints win over inferences, and inferences are reported separately.
35
+ Polymorphic ids are excluded.
36
+ - Association targets resolve against the schema's real table names rather than by
37
+ pluralising, and tables behind an engine prefix are found through the class a
38
+ factory declares - so `Spree::Order` resolves to `spree_orders`.
39
+ - factory_bot and factory_girl definitions are indexed from source and never
40
+ executed, including traits, inheritance through nesting and `parent:`, callbacks,
41
+ and factories that never persist.
42
+ - Gem, auth, tenancy, soft-delete, versioning, feature-flag, attachment and
43
+ test-harness detection from `Gemfile.lock` and the helper files, with per-model
44
+ hazards (`default_scope`, `after_commit`, attachments, `acts_as_tenant`) reported
45
+ by file and line.
46
+ - Unknown schema statements and column types are recorded rather than fatal.
47
+
48
+ ### Planning and inputs
49
+
50
+ - A `SetupPlan` is pure data: no connection, no application code, no application
51
+ process. Its id is content-addressed, so the same target always yields the same
52
+ plan and a changed plan is visibly different.
53
+ - The environment is pinned before any record exists: frozen clock, seeded
54
+ randomness, locale, time zone.
55
+ - A factory is used where one exists and is then left to own its own associations;
56
+ otherwise the required `belongs_to` closure is built from the schema. Nullable
57
+ associations are left null.
58
+ - A current-user record is built only for a target that could observe one.
59
+ - Boundary values and one-factor-at-a-time variation across constructor and method
60
+ parameters, interleaved so a small case budget reaches both. One case omits an
61
+ optional argument entirely, exercising the method's own default expression.
62
+ - `--sample` reads real rows through a generated read-only script in the
63
+ application's runtime, then rebuilds them as `create!` calls. Personal data is
64
+ rewritten preserving domain and length; row sources are hashed.
65
+
66
+ ### Capture
67
+
68
+ - The probe is generated, stdlib-only, and held to a Ruby 2.6 syntax floor, so it
69
+ runs in the application's Ruby. It can be read before it is run.
70
+ - It refuses to run outside `RAILS_ENV=test`, and honours the suite's isolation
71
+ regime - rolling back per case, or truncating where the suite does not transact.
72
+ - No database id reaches a snapshot. Foreign keys pointing at plan-built records
73
+ become refs; other id-shaped values become a wildcard. Ids inside job arguments
74
+ and GlobalID strings are resolved too.
75
+ - Side-effect sinks are forced to `:test` and cleared after setup, so a factory
76
+ callback's enqueue is never attributed to the target.
77
+ - Two boots by default, because one process shares warm caches and an input-keyed
78
+ memo would agree with itself. Cases that differ between boots are reported with a
79
+ named cause rather than pinned.
80
+
81
+ ### Emitted specs
82
+
83
+ - The spec forces the capture's answer on every axis rather than inheriting the
84
+ suite's: isolation regime, queue adapter, clock, seed, locale and zone.
85
+ - Verification runs in three environments - the file alone, a hostile timezone and
86
+ locale with a different seed, and the file twice in one process.
87
+ - Job and mail assertions are block-scoped. A timezone guard is emitted only for a
88
+ target that reads the process clock.
89
+ - pinspec overwrites only its own output; anything else needs `--force`.
90
+ - Example names are deterministic. No captured value is sent anywhere: there is no
91
+ path from the CLI to a language model.
92
+
93
+ ### Refusals
94
+
95
+ Each carries its own exit code: a target that takes a block, a constructor that
96
+ resolves its own dependencies, a parameter naming a model the application has no
97
+ table, model or factory for, mutually required NOT NULL foreign keys, a NOT NULL
98
+ column whose type has no honest value, attachments, `ros-apartment` tenancy, an
99
+ ambiguous target name, a delegation or `method_missing` redirect,
100
+ `db/structure.sql`, Rails below 6.0, and nothing stable to pin.
101
+
102
+ pinspec does not pass `nil` for a model it could not build: the target would raise
103
+ on nil and that error would be pinned as though the application produced it.
104
+
105
+ ### Known limitations
106
+
107
+ - Mutation scores on real service objects are frequently weak. The default corpus
108
+ builds the smallest world that can exist, and the smallest world often does not
109
+ reach a target's branches. Read the scores before trusting a pin; `--sample`
110
+ helps.
111
+ - `after_commit` never fires under transactional isolation, in the capture or in the
112
+ emitted spec. pinspec does not fake it, and the report says so.
113
+ - Per-case rollback covers the primary writing connection only.
114
+ - Attachments are refused rather than run against an empty blob.
115
+ - Clock and attribute-read detection scans the target's own file, so a transitive
116
+ callee is invisible. No warning is not proof of no read.
117
+ - `insta` and `approvals` snapshot backends are not implemented; `--snapshot`
118
+ refuses them rather than silently emitting inline literals.
119
+
120
+ ### Requirements
121
+
122
+ Ruby >= 3.2 for the CLI, and >= 3.4 for `validate`. Rails >= 6.0 in the target
123
+ application. No database gem is added: everything that touches application data
124
+ runs inside the application through `rails runner`.
125
+
126
+ ### Verified
127
+
128
+ 658 examples on Ruby 3.4.6, 3.3.0 and 3.2.2, each under a normal shell and under
129
+ `LANG=C LC_ALL=C TZ=Etc/GMT+8`. Both isolation regimes are covered end to end by
130
+ Rails applications that boot on PostgreSQL, and the six axes on which the probe
131
+ process and the spec process could disagree are held to agreement by a test that
132
+ fails the build. Also exercised against a large open-source Rails application on
133
+ Spree: six service objects pinned, each green in all three verify configurations.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Rehan Munir
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # pinspec
2
+
3
+ Characterization tests for legacy Rails codebases.
4
+
5
+ Point pinspec at a Rails service object or model method. It works out how to invoke
6
+ it, runs it against your test database, writes an RSpec file that freezes what the
7
+ code does today, and then verifies that file passes in your app's own test
8
+ environment.
9
+
10
+ ```bash
11
+ pinspec pin app/services/invoice_calculator.rb#call --app .
12
+ ```
13
+
14
+ ```
15
+ capture InvoiceCalculator#call
16
+ runs 2 boots
17
+ stable 2 of 2
18
+
19
+ emitted spec/characterization/invoice_calculator_call_spec.rb
20
+ pinned c001, c002
21
+ aspects 2 return, 2 jobs
22
+
23
+ verify
24
+ isolated green (4 examples)
25
+ hostile green (4 examples)
26
+ neighbored green (4 examples)
27
+ ```
28
+
29
+ A pin freezes current behaviour. It is not a claim that the behaviour is correct —
30
+ bugs get pinned on purpose, so a refactor cannot change them silently.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ gem install pinspec
36
+ ```
37
+
38
+ Ruby >= 3.2. Rails >= 6.0 in the target app. PostgreSQL, MySQL and SQLite all work:
39
+ pinspec adds no database gem, because everything that touches your data runs inside
40
+ your app through `rails runner`.
41
+
42
+ ## Commands
43
+
44
+ | Command | What it does |
45
+ | --- | --- |
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. |
52
+
53
+ Useful flags: `--cases N`, `--boots N`, `--sample` (read real rows from your
54
+ development database), `--no-redact`, `--force`, `--app-env KEY=VALUE`.
55
+
56
+ The target comes first. `--app-env` is an array option and will swallow it otherwise.
57
+
58
+ ## Verification
59
+
60
+ Every pin is checked in three environments, because one run on the machine that
61
+ captured it proves repeatability rather than portability:
62
+
63
+ - **isolated** — the file alone, as captured.
64
+ - **hostile** — a different timezone, locale and RSpec seed.
65
+ - **neighbored** — the file twice in one process, so accumulated state shows up.
66
+
67
+ The emitted spec forces the capture's answer on every axis rather than inheriting
68
+ the suite's: isolation regime, queue adapter, clock, seed, locale and zone. A suite
69
+ that truncates instead of transacting, or that runs jobs inline, would otherwise
70
+ turn a green capture into a red or vacuous spec.
71
+
72
+ ## No database ids in a pin
73
+
74
+ Postgres sequences are not transactional, so a rolled-back case still advances them
75
+ and an id differs on the next run. A foreign key pointing at a record the plan built
76
+ becomes a ref; anything else id-shaped becomes a wildcard:
77
+
78
+ ```ruby
79
+ {"t" => "record", "class" => "Invoice", "attributes" => {
80
+ "customer_id" => {"t" => "ref", "v" => "customer_1"},
81
+ "total" => {"t" => "decimal", "v" => "110.0"}
82
+ }}
83
+ ```
84
+
85
+ Ids also hide in job arguments (`perform_later(record.id)`) and inside GlobalID
86
+ strings (`gid://app/Invoice/22`). Both are resolved to the record they name, or keep
87
+ the model and drop the id.
88
+
89
+ ## Real rows, with the personal data rewritten
90
+
91
+ `--sample` reads rows from your development database through a generated read-only
92
+ script, then rebuilds them as `create!` calls in the spec. Rewrites preserve **domain
93
+ and length** — `rehan.munir@acme.co` keeps `@acme.co` and its character count — so a
94
+ target that routes on a domain or validates a length behaves as it always did. Row
95
+ sources are hashed, so a committed spec does not map back to production rows.
96
+
97
+ `--no-redact` turns this off. It writes real personal data into a file you commit.
98
+
99
+ ## It refuses rather than guessing
100
+
101
+ | Situation | Result | Exit |
102
+ | --- | --- | --- |
103
+ | target takes a block or yields | `BlockRequired` | 4 |
104
+ | constructor resolves its own dependencies, or `super`s into another file | `UnresolvableSetup(:opaque_constructor)` | 5 |
105
+ | a parameter names a model the app has no table, model or factory for | `UnresolvableSetup(:unresolvable_parameter)` | 5 |
106
+ | two tables require each other through NOT NULL foreign keys | `UnresolvableSetup(:association_cycle)` | 5 |
107
+ | a NOT NULL column whose type has no honest value | `UnresolvableSetup(:unknown_column_type)` | 5 |
108
+ | target reads an attachment | `UnresolvableSetup(:attachment)` | 5 |
109
+ | `ros-apartment` tenancy | `UnresolvableSetup(:apartment)` | 5 |
110
+ | name resolves to two definitions | `AmbiguousTarget` | 3 |
111
+ | `delegate` or `method_missing` redirect | `TargetNotFound` | 2 |
112
+ | `db/structure.sql` instead of `db/schema.rb` | `SchemaFormatUnsupported` | 6 |
113
+ | Rails below 6.0 | `UnsupportedRailsVersion` | 10 |
114
+ | no case was stable across boots | `NothingStableToPin` | 8 |
115
+
116
+ It will not pass `nil` for a model it could not build: the target would raise on nil,
117
+ and that error would be pinned as though your application produced it.
118
+
119
+ ## Mutation scoring
120
+
121
+ `pinspec validate` grades each aspect of a pin separately, because they are blind to
122
+ different things — a return-value pin does not notice a deleted `perform_later`, and
123
+ a job pin does not notice the arithmetic:
124
+
125
+ ```
126
+ return 66.7% weak (4 killed, 2 survived)
127
+ survived: statement_removal at line 18 - SyncJob.perform_later(invoice.id)
128
+ jobs 50.0% weak (3 killed, 3 survived)
129
+ survived: arithmetic at line 14 - +
130
+
131
+ nothing survived every aspect: together, the pins cover this target.
132
+ ```
133
+
134
+ A pin containing a wildcard or a truncated value is never reported as strong, however
135
+ well it scores. An aspect the pin does not assert is skipped rather than scored, since
136
+ scoring an absent aspect yields a vacuous 100%.
137
+
138
+ ## Known limits
139
+
140
+ - **Attachments** are refused rather than run against an empty blob.
141
+ - **`after_commit`** never fires under transactional isolation, in the capture or in
142
+ the emitted spec. pinspec does not fake it, so this is a real divergence from
143
+ production and the report says so.
144
+ - **Multiple writing databases**: per-case rollback covers the primary writing
145
+ connection only.
146
+ - **Read and clock detection scans the target's own file.** A transitive callee that
147
+ reads the current user or the process clock is invisible, so no warning is not proof
148
+ of no read.
149
+ - **Small worlds make weak pins.** The default corpus builds the smallest world that
150
+ can exist, which often does not reach a target's branches — mutation scores on real
151
+ service objects are frequently weak. `--sample` helps; read the scores before
152
+ trusting a pin.
153
+
154
+ ## Development
155
+
156
+ ```bash
157
+ bundle install
158
+ bundle exec rspec
159
+ LANG=C LC_ALL=C TZ=Etc/GMT+8 bundle exec rspec
160
+ ```
161
+
162
+ That second run matters: pinspec's own hostile verify config sets `LANG=C`, so a
163
+ non-ASCII string literal in shipped source would break it.
164
+
165
+ Fixtures come in two kinds. Those under `spec/fixtures/targets/` and most of
166
+ `spec/fixtures/apps/` are parsed, never loaded, so they reference constants that do
167
+ not exist in this repo — that is the point, since the analyzer must work against a
168
+ repo you have not booted. Two of them are real Rails apps that boot on PostgreSQL and
169
+ cover both isolation regimes; the specs needing them skip rather than fail when they
170
+ are not prepared:
171
+
172
+ ```bash
173
+ cd spec/fixtures/apps/rails71_basic && bundle install && RAILS_ENV=test bundle exec rails db:schema:load
174
+ ```
175
+
176
+ `spec/equivalence/host_equivalence_spec.rb` is the one to read first. It holds the
177
+ probe process and the spec process to the same answer across all six axes they could
178
+ disagree on, and each fixture is configured to disagree with the plan so that
179
+ agreement cannot happen by accident.
180
+
181
+ ## License
182
+
183
+ MIT
data/exe/pinspec ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+
6
+ require "pinspec"
7
+
8
+ Pinspec::CLI.start(ARGV)