ts_schema_spec 0.5.4
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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +288 -0
- data/lib/ts_schema_spec/config.rb +21 -0
- data/lib/ts_schema_spec/generator.rb +65 -0
- data/lib/ts_schema_spec/railtie.rb +11 -0
- data/lib/ts_schema_spec/react_component_props.rb +43 -0
- data/lib/ts_schema_spec/rspec.rb +123 -0
- data/lib/ts_schema_spec/skill.rb +62 -0
- data/lib/ts_schema_spec/tasks/ts_schema_spec.rake +12 -0
- data/lib/ts_schema_spec/version.rb +5 -0
- data/lib/ts_schema_spec.rb +74 -0
- data/skills/react-prop-type-spec/SKILL.md +130 -0
- metadata +88 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c8202e8af0fa27c82203f869cd593208ee48dd95bfe23f6ab53083c47442fc47
|
|
4
|
+
data.tar.gz: 3afcb839cc834657613896897ba454234a07facc8b0ffc3a9e8ce54abea5dffb
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a4608416aebf93c1125f9df04126b44479f52bf95e7605f924aa14af04b18fbafef30f39e3cf0ff16c38b948d9f970b37fb552b867cb9725764d569b83cdb1d4
|
|
7
|
+
data.tar.gz: 8ff1849e182079b473b130922e9315dfc92766c5d84bad559d52e715c01e3ffbc86effafa971b445f2fc2ee307a6819be81f45266d9441c07333c0f658b38600
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vernon Coffey
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# ts_schema_spec
|
|
2
|
+
|
|
3
|
+
Add tests to your Ruby test suite that assert a JSON payload generated in Ruby
|
|
4
|
+
matches the TypeScript type of the consumer.
|
|
5
|
+
|
|
6
|
+
Built for RSpec, but could be ported to other test frameworks (Minitest, etc.).
|
|
7
|
+
|
|
8
|
+
`react_component_props`, an optional React-specific helper, reads props out of
|
|
9
|
+
rendered mounts.
|
|
10
|
+
|
|
11
|
+
React is the case this gem was built for, not a requirement. `match_schema`
|
|
12
|
+
checks any payload against any exported TypeScript type, so a Stimulus
|
|
13
|
+
controller or a plain fetch client works the same way.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# Gemfile
|
|
19
|
+
gem "ts_schema_spec", github: "nitidbit/ts_schema_spec", tag: "v0.5.3", group: :test
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
This gem requires ts-json-schema-generator, resolved from your project's
|
|
23
|
+
`node_modules`:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
npm install --save-dev ts-json-schema-generator
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Add the following to your test suite:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
# spec/rails_helper.rb
|
|
33
|
+
require "ts_schema_spec/rspec"
|
|
34
|
+
require "ts_schema_spec/react_component_props" # optional, for parsing props from rendered HTML. See below.
|
|
35
|
+
|
|
36
|
+
RSpec.configure do |config|
|
|
37
|
+
config.include TsSchemaSpec::ReactComponentProps
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Path aliases
|
|
42
|
+
|
|
43
|
+
If your TypeScript imports through aliases (`@/components/Foo`), point the
|
|
44
|
+
generator at your tsconfig:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
# spec/rails_helper.rb
|
|
48
|
+
TsSchemaSpec.configure do |config|
|
|
49
|
+
config.tsconfig = Rails.root.join("tsconfig.json").to_s
|
|
50
|
+
config.generator_args = [] # anything else to pass through
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**This is not optional decoration.** An import the generator cannot resolve
|
|
55
|
+
does not fail — it becomes an empty schema, and an empty schema validates
|
|
56
|
+
anything:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
"role": {}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
So an aliased type without a tsconfig gives you a green spec that accepts a
|
|
63
|
+
string, a number or null where an object was declared. If any type you assert
|
|
64
|
+
on imports through an alias, set this.
|
|
65
|
+
|
|
66
|
+
### The agent skill
|
|
67
|
+
|
|
68
|
+
The gem ships the skill that teaches an agent when one of these specs is owed
|
|
69
|
+
and how to write it. Claude Code reads skills from the working tree, so it has
|
|
70
|
+
to be copied in and committed:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
RAILS_ENV=test bundle exec rake ts_schema_spec:install_skill
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`RAILS_ENV=test` is required when the gem is in `group: :test`, as above.
|
|
77
|
+
|
|
78
|
+
It lands in `.claude/skills/react-prop-type-spec/SKILL.md`, stamped with the
|
|
79
|
+
gem version. To keep the skill in sync with the gem version:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
# spec/ts_schema_spec_skill_spec.rb
|
|
83
|
+
it "has the skill matching the installed gem" do
|
|
84
|
+
expect { TsSchemaSpec::Skill.check!(Rails.root) }.to_not raise_error
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Use
|
|
89
|
+
|
|
90
|
+
### A JSON endpoint
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
it "matches AccountPayload" do
|
|
94
|
+
create(:account, :with_roles)
|
|
95
|
+
create(:account, :unassigned)
|
|
96
|
+
|
|
97
|
+
get :index, format: :json
|
|
98
|
+
|
|
99
|
+
expect(response).to be_successful
|
|
100
|
+
expect(response.parsed_body["accounts"])
|
|
101
|
+
.to match_schema("app/javascript/types/account.ts", "AccountPayload")
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Props from an HTML mount
|
|
106
|
+
|
|
107
|
+
`react_component_props` parses the rendered `data-react-props` attributes and
|
|
108
|
+
returns **an array** — one entry per mount of that component on the page. It
|
|
109
|
+
needs `render_views`.
|
|
110
|
+
|
|
111
|
+
It assumes [react-rails](https://github.com/reactjs/react-rails) conventions:
|
|
112
|
+
`data-react-class` and `data-react-props` on the mount element. A namespaced
|
|
113
|
+
class matches on its trailing segment, so `admin/SidebarNav` and
|
|
114
|
+
`Admin.SidebarNav` both answer to `"SidebarNav"`.
|
|
115
|
+
|
|
116
|
+
Other integrations mount differently — react_on_rails uses its own attributes
|
|
117
|
+
— and against those it finds nothing and reports an empty collection rather
|
|
118
|
+
than a missing-attribute error. Supporting another convention is a small
|
|
119
|
+
change to one file; open a PR if you need it.
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
describe "the props handed to RoleMatrix" do
|
|
123
|
+
render_views
|
|
124
|
+
|
|
125
|
+
role_matrix = "app/javascript/components/roles/RoleMatrix.tsx"
|
|
126
|
+
|
|
127
|
+
it "matches RoleMatrixProps" do
|
|
128
|
+
account = create(:account, :with_roles)
|
|
129
|
+
|
|
130
|
+
get :show, params: { id: account.id }
|
|
131
|
+
|
|
132
|
+
expect(response).to be_successful
|
|
133
|
+
props = react_component_props("RoleMatrix")
|
|
134
|
+
expect(props).to match_schema(role_matrix, "RoleMatrixProps")
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`match_schema` accepts a hash or an array of hashes. Given an array, it
|
|
140
|
+
validates every item and fails on an empty one — in both directions, so
|
|
141
|
+
`to_not match_schema` does not pass vacuously either. A page that stopped
|
|
142
|
+
rendering the component fails rather than passing silently. The alternative
|
|
143
|
+
construction, `expect(props).to all match_schema(...)`, would pass on an empty
|
|
144
|
+
array.
|
|
145
|
+
|
|
146
|
+
## API
|
|
147
|
+
|
|
148
|
+
| Call | Returns |
|
|
149
|
+
| --------------------------------------- | ----------------------------------------------------------- |
|
|
150
|
+
| `TsSchemaSpec.schema_for(path, type)` | a `JSONSchemer` schema scoped to that exported type |
|
|
151
|
+
| `match_schema(path, type)` | matcher; validates a hash, or every item of an array |
|
|
152
|
+
| `react_component_props(name[, html])` | array of props hashes, one per mount |
|
|
153
|
+
| `TsSchemaSpec::Skill.check!(root)` | raises if the installed skill is stale or missing |
|
|
154
|
+
| `TsSchemaSpec.configure` | sets `tsconfig` and extra generator arguments |
|
|
155
|
+
| `TsSchemaSpec.clear_cache!` | drops the generated-schema cache |
|
|
156
|
+
|
|
157
|
+
`path` is resolved from wherever the suite runs, which is the Rails root in
|
|
158
|
+
practice. Name the type at the assertion, so an example says which type it is
|
|
159
|
+
checking. When several examples read the same source, bind the path to a local
|
|
160
|
+
variable or a `let` — a constant assigned inside a `describe` block is global,
|
|
161
|
+
so two spec files that both use one will collide.
|
|
162
|
+
|
|
163
|
+
`schema_for` stays public, but is not needed when using the RSpec matcher,
|
|
164
|
+
which calls it internally.
|
|
165
|
+
|
|
166
|
+
## What a failure looks like
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
expected the payload to match Role (app/javascript/types/role.ts), but:
|
|
170
|
+
- /id: value at `/id` is not a number
|
|
171
|
+
- /created_at: object property at `/created_at` is a disallowed additional property
|
|
172
|
+
- (root): object at root is missing required properties: shortcode
|
|
173
|
+
|
|
174
|
+
Payload was:
|
|
175
|
+
{
|
|
176
|
+
"id": "1",
|
|
177
|
+
"name": "Nurse",
|
|
178
|
+
"created_at": "2026-09-11"
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Best practices
|
|
183
|
+
|
|
184
|
+
**Build records that cover the variations.** One response only exercises the
|
|
185
|
+
branches it took. When the type has optional fields, enum values or nullable
|
|
186
|
+
associations, create as many records, with as many different traits, as it
|
|
187
|
+
takes to put those branches in the payload.
|
|
188
|
+
|
|
189
|
+
**Tighten the type first.** A schema is only as strong as the type it comes
|
|
190
|
+
from, and an all-optional type is satisfied by `{}` — asserting against one
|
|
191
|
+
passes while catching nothing, which is worse than no spec, because it reads
|
|
192
|
+
as coverage.
|
|
193
|
+
|
|
194
|
+
- A field the server always sends: **required**.
|
|
195
|
+
- A field that can be null: `string | null`, not `string?`.
|
|
196
|
+
- A field with a fixed set of values: a literal union
|
|
197
|
+
(`"draft" | "published"`), not `string` — which catches a typo and a value
|
|
198
|
+
the consumer was never taught about.
|
|
199
|
+
- An index signature or `Record<string, unknown>`: declare the keys the
|
|
200
|
+
component actually reads, required, alongside it.
|
|
201
|
+
|
|
202
|
+
A literal union only stays honest while it tracks the Ruby enum it mirrors,
|
|
203
|
+
which needs some way of sharing constants from Ruby into TypeScript — a
|
|
204
|
+
generator, a shared JSON file, whatever suits your repo. That is outside this
|
|
205
|
+
gem's scope, but with one in place, and a record for each value in the
|
|
206
|
+
example, a drifted union fails here rather than in the browser.
|
|
207
|
+
|
|
208
|
+
**Pass the whole collection.** `match_schema` validates every item and fails
|
|
209
|
+
on an empty one. Avoid `expect(props).to all match_schema(...)`, which
|
|
210
|
+
passes on an empty array, hiding an issue with generation.
|
|
211
|
+
|
|
212
|
+
**Assert the response is successful first.** Otherwise a redirect or a 500
|
|
213
|
+
arrives as a schema failure, and you debug the wrong thing.
|
|
214
|
+
|
|
215
|
+
**Let the matcher do the shape checking.** Hand-written field assertions
|
|
216
|
+
alongside it duplicate what the type already says, and go stale separately.
|
|
217
|
+
|
|
218
|
+
**When it fails, fix Rails.** The type is the consumer's contract: if the
|
|
219
|
+
component needs a field, the payload is wrong. Loosen the type only when the
|
|
220
|
+
component genuinely does not need what it declares — loosening is always the
|
|
221
|
+
quicker route to green, and it is how this stops catching anything.
|
|
222
|
+
|
|
223
|
+
## Gotchas
|
|
224
|
+
|
|
225
|
+
**Undeclared keys fail.** `ts-json-schema-generator` emits
|
|
226
|
+
`additionalProperties: false`, so a payload carrying a key the TypeScript does
|
|
227
|
+
not declare is an error, not a warning. That is deliberate — it catches Rails
|
|
228
|
+
sending something nobody typed — but expect it when an `as_json` emits
|
|
229
|
+
timestamps the React side ignores. Fix by declaring the field or narrowing the
|
|
230
|
+
`only:`.
|
|
231
|
+
|
|
232
|
+
## Cost
|
|
233
|
+
|
|
234
|
+
One `npx ts-json-schema-generator` run per source file. Generated schemas are
|
|
235
|
+
cached in memory, keyed by the file and the generator arguments, so reading
|
|
236
|
+
three types out of one `.ts` costs one run rather than three, and changing
|
|
237
|
+
`TsSchemaSpec.configure` regenerates rather than serving a stale schema.
|
|
238
|
+
|
|
239
|
+
The cache lives in the process, so parallel test workers each pay for it once.
|
|
240
|
+
|
|
241
|
+
## Troubleshooting
|
|
242
|
+
|
|
243
|
+
| Symptom | Cause |
|
|
244
|
+
| ---------------------------------------------------- | -------------------------------------------------------------- |
|
|
245
|
+
| `GenerationError: ... Is it exported?` | the type has no `export`, or the name is misspelled |
|
|
246
|
+
| `GenerationError` listing a rerunnable command | run it — the generator's own stderr is in the message |
|
|
247
|
+
| passes against an obviously wrong payload | the type is all-optional, or an unresolved import became `{}` |
|
|
248
|
+
| `could not run npx ts-json-schema-generator` | the generator is not in your `node_modules` |
|
|
249
|
+
| `disallowed additional property` | see above — the payload sends what TypeScript does not declare |
|
|
250
|
+
| `has no data-react-props attribute` | hand-written markup, or a mount from another integration |
|
|
251
|
+
|
|
252
|
+
## What this can't catch
|
|
253
|
+
|
|
254
|
+
Worth knowing before you rely on it.
|
|
255
|
+
|
|
256
|
+
**Coverage is discipline-dependent.** This protects the actions somebody wrote
|
|
257
|
+
a spec for; an uncovered endpoint is exactly as exposed as before. That is what
|
|
258
|
+
the skill is for, and it is convention rather than enforcement.
|
|
259
|
+
|
|
260
|
+
**It only checks what TypeScript declares.** If Rails *intends* to send a field
|
|
261
|
+
nobody typed — say `as_json(only:)` carrying a misspelled attribute, which
|
|
262
|
+
Rails drops silently — no generated schema requires it, so nothing fails. A
|
|
263
|
+
structured serializer catches that class of mistake; this does not.
|
|
264
|
+
|
|
265
|
+
## Alternatives
|
|
266
|
+
|
|
267
|
+
An alternate methodology is to generate the TypeScript types from Ruby.
|
|
268
|
+
|
|
269
|
+
[Typelizer](https://typelizer.dev/) and
|
|
270
|
+
[types_from_serializers](https://github.com/ElMassimo/types_from_serializers)
|
|
271
|
+
make Ruby canonical and generate the types, so drift becomes structurally
|
|
272
|
+
impossible rather than something you test for. That beats this.
|
|
273
|
+
|
|
274
|
+
They need serialization to exist somewhere nameable — they introspect Alba,
|
|
275
|
+
AMS, Oj, Panko or oj_serializers. An app that is plain `as_json` and
|
|
276
|
+
`render json:`, deciding payload shapes inline at each call site, has no
|
|
277
|
+
artifact for a generator to read. This gem is for that case: if a shape only
|
|
278
|
+
exists at its call site, the only way to learn it is to run the action and
|
|
279
|
+
look.
|
|
280
|
+
|
|
281
|
+
The other camp — [json_matchers](https://github.com/thoughtbot/json_matchers),
|
|
282
|
+
committee, rswag — validates against a hand-maintained JSON Schema file. That
|
|
283
|
+
is a third artifact to keep in sync with both sides, which is the same drift
|
|
284
|
+
problem relocated.
|
|
285
|
+
|
|
286
|
+
One gap stays open whichever you pick: `react_component` props. Typelizer
|
|
287
|
+
types serializer output and knows nothing about the outer props object a view
|
|
288
|
+
assembles, so nothing generated from your serializers covers it.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TsSchemaSpec
|
|
4
|
+
# Repo-level generator settings. `tsconfig` matters more than it looks: an
|
|
5
|
+
# import the generator cannot resolve becomes an empty schema, which
|
|
6
|
+
# validates anything, so a repo using path aliases needs this set or its
|
|
7
|
+
# aliased types silently assert nothing.
|
|
8
|
+
class Config
|
|
9
|
+
attr_accessor :tsconfig, :generator_args
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@generator_args = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_args
|
|
16
|
+
args = []
|
|
17
|
+
args += ["--tsconfig", tsconfig.to_s] if tsconfig
|
|
18
|
+
args + Array(generator_args)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module TsSchemaSpec
|
|
7
|
+
module Generator
|
|
8
|
+
COMMAND = %w[npx ts-json-schema-generator].freeze
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def generate(source)
|
|
12
|
+
command = command_for(source)
|
|
13
|
+
stdout, stderr, status = Open3.capture3(*command)
|
|
14
|
+
|
|
15
|
+
unless status.success?
|
|
16
|
+
raise GenerationError, failure(command, stderr)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
JSON.parse(stdout)
|
|
20
|
+
rescue JSON::ParserError => e
|
|
21
|
+
raise GenerationError, failure(command, "#{e.message}\n\n#{stderr}")
|
|
22
|
+
rescue Errno::ENOENT
|
|
23
|
+
raise GenerationError, missing_generator(command)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def command_for(source)
|
|
27
|
+
COMMAND + ["--path", source.to_s, "--no-type-check"] + TsSchemaSpec.config.to_args
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# npx's own phrasing for "the package is not installed". Anything looser
|
|
33
|
+
# — a bare "not found" — swallows the generator's real diagnostic.
|
|
34
|
+
MISSING_PACKAGE = /missing packages|could not determine executable|npx.*not found|not found.*npx/i
|
|
35
|
+
|
|
36
|
+
def missing_generator(command, stderr = nil)
|
|
37
|
+
message = <<~MSG
|
|
38
|
+
could not run #{COMMAND.join(" ")}.
|
|
39
|
+
|
|
40
|
+
The generator parses your app's TypeScript, so it resolves from your
|
|
41
|
+
node_modules rather than from the gem:
|
|
42
|
+
|
|
43
|
+
npm install --save-dev ts-json-schema-generator
|
|
44
|
+
|
|
45
|
+
Rerun: #{command.join(" ")}
|
|
46
|
+
MSG
|
|
47
|
+
return message if stderr.to_s.strip.empty?
|
|
48
|
+
|
|
49
|
+
"#{message}\n#{stderr.strip}\n"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def failure(command, stderr)
|
|
53
|
+
return missing_generator(command, stderr) if stderr.to_s.match?(MISSING_PACKAGE)
|
|
54
|
+
|
|
55
|
+
<<~MSG
|
|
56
|
+
ts-json-schema-generator failed.
|
|
57
|
+
|
|
58
|
+
Rerun: #{command.join(" ")}
|
|
59
|
+
|
|
60
|
+
#{stderr.strip}
|
|
61
|
+
MSG
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "nokogiri"
|
|
5
|
+
|
|
6
|
+
require_relative "../ts_schema_spec"
|
|
7
|
+
|
|
8
|
+
module TsSchemaSpec
|
|
9
|
+
# Pulls props out of react-rails mount points in rendered HTML, so an HTML
|
|
10
|
+
# action's props can be schema-checked the same way a JSON payload is.
|
|
11
|
+
# Needs `render_views`.
|
|
12
|
+
module ReactComponentProps
|
|
13
|
+
def react_component_props(component_name, html = nil)
|
|
14
|
+
html ||= response.body
|
|
15
|
+
|
|
16
|
+
Nokogiri::HTML(html).css("[data-react-class]").filter_map do |node|
|
|
17
|
+
next unless react_class_matches?(node["data-react-class"], component_name)
|
|
18
|
+
|
|
19
|
+
parse_props(node, component_name)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# react-rails namespaces a component either way: "admin/SidebarNav" from
|
|
26
|
+
# the asset path, "Admin.SidebarNav" from the global its UJS resolves.
|
|
27
|
+
NAMESPACE_SEPARATOR = %r{[./]}
|
|
28
|
+
|
|
29
|
+
def react_class_matches?(react_class, component_name)
|
|
30
|
+
react_class == component_name ||
|
|
31
|
+
react_class.to_s.split(NAMESPACE_SEPARATOR).last == component_name
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parse_props(node, component_name)
|
|
35
|
+
raw = node["data-react-props"]
|
|
36
|
+
raise Error, "the #{component_name} mount has no data-react-props attribute" if raw.nil?
|
|
37
|
+
|
|
38
|
+
JSON.parse(raw)
|
|
39
|
+
rescue JSON::ParserError => e
|
|
40
|
+
raise Error, "could not parse data-react-props for #{component_name}: #{e.message}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rspec/expectations"
|
|
5
|
+
|
|
6
|
+
require_relative "../ts_schema_spec"
|
|
7
|
+
|
|
8
|
+
module TsSchemaSpec
|
|
9
|
+
# A payload is often a collection — every mount of a component on the page,
|
|
10
|
+
# every record in an index response. Validating each item against an object
|
|
11
|
+
# schema is the common case, so the matcher handles it rather than leaving
|
|
12
|
+
# callers to reach for `all`, which passes vacuously on an empty collection
|
|
13
|
+
# and so silently asserts nothing.
|
|
14
|
+
module Matching
|
|
15
|
+
EMPTY = :empty_collection
|
|
16
|
+
|
|
17
|
+
BRANCHES = %w[anyOf oneOf allOf].freeze
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def errors(schema, actual)
|
|
21
|
+
return schema.validate(actual).to_a unless collection?(schema, actual)
|
|
22
|
+
return [EMPTY] if actual.empty?
|
|
23
|
+
|
|
24
|
+
actual.each_with_index.flat_map do |item, index|
|
|
25
|
+
schema.validate(item).to_a.map do |error|
|
|
26
|
+
error.merge("data_pointer" => "/#{index}#{error["data_pointer"]}")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def empty?(errors)
|
|
32
|
+
errors == [EMPTY]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def collection?(schema, actual)
|
|
38
|
+
actual.is_a?(Array) && !describes_array?(schema)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A type can reach "array" through a union or an alias, so the literal
|
|
42
|
+
# `"type"` of the schema is not enough: `Role[] | null` and
|
|
43
|
+
# `type Roles = RoleList` both describe an array without saying so here.
|
|
44
|
+
def describes_array?(schema, value = schema.value, seen = [])
|
|
45
|
+
return false unless value.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
if (pointer = value["$ref"])
|
|
48
|
+
return false if seen.include?(pointer)
|
|
49
|
+
|
|
50
|
+
return describes_array?(schema, resolve(schema, pointer), seen + [pointer])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
return true if Array(value["type"]).include?("array")
|
|
54
|
+
|
|
55
|
+
BRANCHES.any? do |branch|
|
|
56
|
+
Array(value[branch]).any? { |option| describes_array?(schema, option, seen) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def resolve(schema, pointer)
|
|
61
|
+
schema.ref(pointer).value
|
|
62
|
+
rescue StandardError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
RSpec::Matchers.define :match_schema do |source, type|
|
|
70
|
+
def validation_errors(actual, source, type)
|
|
71
|
+
schema = TsSchemaSpec.schema_for(source, type)
|
|
72
|
+
@errors = TsSchemaSpec::Matching.errors(schema, actual)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Worded for both directions: this message is what an empty collection gets
|
|
76
|
+
# whichever way the assertion was written.
|
|
77
|
+
def empty_collection_message
|
|
78
|
+
<<~MSG
|
|
79
|
+
expected a non-empty collection, but it was empty, so nothing was
|
|
80
|
+
validated.
|
|
81
|
+
|
|
82
|
+
Usually the component was not rendered on the page, or no records
|
|
83
|
+
existed for the endpoint to serialize. If an empty result is what you
|
|
84
|
+
meant to assert, use `be_empty` or `eq([])` — match_schema on an empty
|
|
85
|
+
collection checks nothing.
|
|
86
|
+
MSG
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
match do |actual|
|
|
90
|
+
validation_errors(actual, source, type).empty?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# An empty collection fails either way round: negating the matcher would
|
|
94
|
+
# otherwise turn the vacuous pass back on.
|
|
95
|
+
match_when_negated do |actual|
|
|
96
|
+
errors = validation_errors(actual, source, type)
|
|
97
|
+
errors.any? && !TsSchemaSpec::Matching.empty?(errors)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
failure_message do |actual|
|
|
101
|
+
next empty_collection_message if TsSchemaSpec::Matching.empty?(@errors)
|
|
102
|
+
|
|
103
|
+
details = @errors.map do |error|
|
|
104
|
+
pointer = error["data_pointer"]
|
|
105
|
+
pointer = "(root)" if pointer.nil? || pointer.empty?
|
|
106
|
+
" - #{pointer}: #{error["error"]}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
<<~MSG
|
|
110
|
+
expected the payload to match #{type} (#{source}), but:
|
|
111
|
+
#{details.join("\n")}
|
|
112
|
+
|
|
113
|
+
Payload was:
|
|
114
|
+
#{JSON.pretty_generate(actual)}
|
|
115
|
+
MSG
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
failure_message_when_negated do |actual|
|
|
119
|
+
next empty_collection_message if TsSchemaSpec::Matching.empty?(@errors)
|
|
120
|
+
|
|
121
|
+
"expected the payload not to match #{type} (#{source}), but it did:\n#{JSON.pretty_generate(actual)}"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
require_relative "../ts_schema_spec"
|
|
6
|
+
|
|
7
|
+
module TsSchemaSpec
|
|
8
|
+
# The skill is instructions an agent follows to write these specs, so a
|
|
9
|
+
# silently outdated copy teaches the wrong API. The copy carries the gem
|
|
10
|
+
# version and `check!` fails when the two diverge.
|
|
11
|
+
module Skill
|
|
12
|
+
NAME = "react-prop-type-spec"
|
|
13
|
+
INSTALL_PATH = ".claude/skills/#{NAME}/SKILL.md"
|
|
14
|
+
SOURCE_PATH = File.expand_path("../../skills/#{NAME}/SKILL.md", __dir__)
|
|
15
|
+
STAMP = "ts_schema_spec_version"
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def install(root = Dir.pwd)
|
|
19
|
+
destination = File.join(root, INSTALL_PATH)
|
|
20
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
21
|
+
File.write(destination, stamped(File.read(SOURCE_PATH)))
|
|
22
|
+
destination
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def installed_version(root = Dir.pwd)
|
|
26
|
+
contents = File.read(File.join(root, INSTALL_PATH))
|
|
27
|
+
contents[/^#{STAMP}: (.+)$/, 1]
|
|
28
|
+
rescue Errno::ENOENT
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def check!(root = Dir.pwd)
|
|
33
|
+
found = installed_version(root)
|
|
34
|
+
return true if found == TsSchemaSpec::VERSION
|
|
35
|
+
|
|
36
|
+
raise Error, <<~MSG
|
|
37
|
+
The #{NAME} skill in #{INSTALL_PATH} is #{found ? "stale (#{found})" : "not installed"};
|
|
38
|
+
the gem is #{TsSchemaSpec::VERSION}.
|
|
39
|
+
|
|
40
|
+
Run: bundle exec rake ts_schema_spec:install_skill
|
|
41
|
+
MSG
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def stamped(contents)
|
|
47
|
+
body = contents.sub(/^#{STAMP}: .*\n/, "")
|
|
48
|
+
stamped = body.sub(/\A---\n/, "---\n#{STAMP}: #{TsSchemaSpec::VERSION}\n")
|
|
49
|
+
|
|
50
|
+
if stamped == body
|
|
51
|
+
raise Error, <<~MSG
|
|
52
|
+
#{SOURCE_PATH} does not open with YAML frontmatter, so there is
|
|
53
|
+
nowhere to put the version stamp that check! reads. Installing it
|
|
54
|
+
unstamped would fail that check for good.
|
|
55
|
+
MSG
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
stamped
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :ts_schema_spec do
|
|
4
|
+
desc "Copy the react-prop-type-spec agent skill into .claude/skills"
|
|
5
|
+
task :install_skill do
|
|
6
|
+
require "ts_schema_spec/skill"
|
|
7
|
+
|
|
8
|
+
destination = TsSchemaSpec::Skill.install(defined?(Rails) ? Rails.root.to_s : Dir.pwd)
|
|
9
|
+
puts "Installed #{TsSchemaSpec::Skill::NAME} v#{TsSchemaSpec::VERSION} to #{destination}"
|
|
10
|
+
puts "Commit it — Claude Code reads skills from the working tree."
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json_schemer"
|
|
4
|
+
|
|
5
|
+
require_relative "ts_schema_spec/version"
|
|
6
|
+
require_relative "ts_schema_spec/config"
|
|
7
|
+
require_relative "ts_schema_spec/generator"
|
|
8
|
+
|
|
9
|
+
require_relative "ts_schema_spec/railtie" if defined?(Rails::Railtie)
|
|
10
|
+
|
|
11
|
+
module TsSchemaSpec
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
class GenerationError < Error; end
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def schema_for(source, type)
|
|
17
|
+
validate_arguments!(source, type)
|
|
18
|
+
document = document_for(source)
|
|
19
|
+
|
|
20
|
+
unless document.fetch("definitions", {}).key?(type)
|
|
21
|
+
raise GenerationError, <<~MSG
|
|
22
|
+
#{source} does not define #{type.inspect}.
|
|
23
|
+
|
|
24
|
+
Generated definitions: #{document.fetch("definitions", {}).keys.sort.join(", ")}
|
|
25
|
+
|
|
26
|
+
Is it exported?
|
|
27
|
+
MSG
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
JSONSchemer.schema(document).ref("#/definitions/#{type}")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def clear_cache!
|
|
34
|
+
documents.clear
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def config
|
|
38
|
+
@config ||= Config.new
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def configure
|
|
42
|
+
yield(config)
|
|
43
|
+
clear_cache!
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def reset_config!
|
|
47
|
+
@config = nil
|
|
48
|
+
clear_cache!
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
PATH_LIKE = %r{/|\.tsx?\z}
|
|
54
|
+
|
|
55
|
+
def validate_arguments!(source, type)
|
|
56
|
+
raise ArgumentError, "expects a path and a type name, e.g. schema_for(\"app/javascript/Foo.tsx\", \"FooProps\")" if type.nil?
|
|
57
|
+
|
|
58
|
+
return unless type.to_s.match?(PATH_LIKE) && !source.to_s.match?(PATH_LIKE)
|
|
59
|
+
|
|
60
|
+
raise ArgumentError, <<~MSG
|
|
61
|
+
the arguments look reversed: #{source.inspect} was given as the source
|
|
62
|
+
file and #{type.inspect} as the type name. Expected (path, type).
|
|
63
|
+
MSG
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def document_for(source)
|
|
67
|
+
documents[[File.expand_path(source.to_s), config.to_args]] ||= Generator.generate(source)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def documents
|
|
71
|
+
@documents ||= {}
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: react-prop-type-spec
|
|
3
|
+
description: >
|
|
4
|
+
Write or update RSpec tests that use match_schema to verify a Rails
|
|
5
|
+
endpoint's payload matches the TypeScript type that consumes it. TRIGGER
|
|
6
|
+
automatically (without being asked) whenever data crossing from Ruby to
|
|
7
|
+
TypeScript is added or changed: adding a controller action; changing
|
|
8
|
+
serialization in an existing one (helper method, as_json fields, included
|
|
9
|
+
associations); adding or renaming a key in a render json: response or in
|
|
10
|
+
props handed to a component; converting a Rails-mounted component from .jsx
|
|
11
|
+
to .tsx; deleting a component's propTypes; or rendering an already-typed
|
|
12
|
+
component from an action that has no match_schema spec. React is the common
|
|
13
|
+
case, not a requirement — a Stimulus controller or a plain fetch client
|
|
14
|
+
reading the payload counts the same. Invoked as /react-prop-type-spec.
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
A spec is owed any time data is handed from Ruby to TypeScript — not only when
|
|
18
|
+
something changes, and including where there is no Ruby diff at all. Two rules
|
|
19
|
+
keep that from multiplying:
|
|
20
|
+
|
|
21
|
+
- **Repeated mounts of one component are a single example.**
|
|
22
|
+
`react_component_props` returns every mount and `match_schema` checks each.
|
|
23
|
+
- **Assert on the component Rails mounts.** A child receiving props from its
|
|
24
|
+
parent is covered transitively; use the parent's props type.
|
|
25
|
+
|
|
26
|
+
## Step 0 — Already covered?
|
|
27
|
+
|
|
28
|
+
Covered = the spec for **the action rendering it** has a `match_schema` example
|
|
29
|
+
on that component. A sibling component, or the same component from another
|
|
30
|
+
action, is not coverage. Covered → stop.
|
|
31
|
+
|
|
32
|
+
## Step 1 — Find the TypeScript consumer
|
|
33
|
+
|
|
34
|
+
- **JSON** (`render json:`): find the `fetch`/`axios` call and the type it
|
|
35
|
+
parses into.
|
|
36
|
+
- **HTML** (`react_component`): find the view, the component it mounts, and its
|
|
37
|
+
props interface.
|
|
38
|
+
|
|
39
|
+
React is the common case, not a requirement — a payload read by a Stimulus
|
|
40
|
+
controller, a plain fetch client or any other TypeScript module is checked the
|
|
41
|
+
same way, via `response.parsed_body`. Only `react_component_props` is
|
|
42
|
+
React-specific.
|
|
43
|
+
|
|
44
|
+
No TypeScript consumer at all → exit the skill.
|
|
45
|
+
|
|
46
|
+
## Step 2 — Export the type
|
|
47
|
+
|
|
48
|
+
`ts-json-schema-generator` only targets exported types. Add `export` if it is
|
|
49
|
+
missing.
|
|
50
|
+
|
|
51
|
+
## Step 3 — Tighten the type
|
|
52
|
+
|
|
53
|
+
An all-optional type is satisfied by `{}`, so asserting on one asserts nothing.
|
|
54
|
+
|
|
55
|
+
- A field the server always sends: **required**.
|
|
56
|
+
- A field the server can send as null: `string | null`, not `string?`.
|
|
57
|
+
- A field with a fixed set of values: a literal union (`"draft" | "published"`),
|
|
58
|
+
not `string`.
|
|
59
|
+
- An index signature or `Record<string, unknown>`: keep the keys the component
|
|
60
|
+
actually reads **required alongside it**.
|
|
61
|
+
|
|
62
|
+
This edits the app's TypeScript rather than the test, so say that you did it. If
|
|
63
|
+
it cannot be tightened now, report which fields are unconstrained rather than
|
|
64
|
+
implying the spec covers them.
|
|
65
|
+
|
|
66
|
+
## Step 4 — Write the test
|
|
67
|
+
|
|
68
|
+
| Action | Data source |
|
|
69
|
+
| ------ | ----------- |
|
|
70
|
+
| `render json:` | `response.parsed_body["key"]` |
|
|
71
|
+
| `react_component` | `react_component_props("ComponentName")` |
|
|
72
|
+
|
|
73
|
+
`react_component_props` returns **an array**, one entry per mount, and needs
|
|
74
|
+
`render_views`. Pass it straight to `match_schema`: it validates every entry
|
|
75
|
+
and fails on an empty collection. Never wrap it in `all`, which iterates zero
|
|
76
|
+
times on an empty array and asserts nothing.
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
describe "the props handed to MyComponent" do
|
|
80
|
+
render_views
|
|
81
|
+
|
|
82
|
+
it "matches MyComponentProps" do
|
|
83
|
+
record = create(:factory_name, trait_a: true)
|
|
84
|
+
create(:factory_name, :some_trait)
|
|
85
|
+
|
|
86
|
+
get :show, params: { id: record.id }
|
|
87
|
+
|
|
88
|
+
expect(response).to be_successful
|
|
89
|
+
props = react_component_props("MyComponent")
|
|
90
|
+
expect(props).to match_schema("app/javascript/MyComponent.tsx", "MyComponentProps")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Name the file and type at the assertion. When several examples read the same
|
|
96
|
+
source, bind the path to a constant or a let variable.
|
|
97
|
+
|
|
98
|
+
Rules:
|
|
99
|
+
|
|
100
|
+
- Build multiple records with different traits, so optional fields, enum values
|
|
101
|
+
and nil associations are actually exercised. Use existing factory traits.
|
|
102
|
+
- Assert `response` is successful before asserting shape.
|
|
103
|
+
- Let `match_schema` do the shape checking; no hand-written field assertions.
|
|
104
|
+
- Several components in one action: an example each, or one example marked
|
|
105
|
+
`:aggregate_failures` if rendering the page is expensive — without it the
|
|
106
|
+
first mismatch hides the rest.
|
|
107
|
+
- Do not write a spec that only checks `response.status`, and do not duplicate
|
|
108
|
+
an existing `match_schema` for the same action.
|
|
109
|
+
- **When it fails, fix Rails.** The type is the consumer's contract: if the
|
|
110
|
+
component needs a field, the payload is wrong. Loosening the type is always
|
|
111
|
+
the quicker route to green, and it is how this stops catching anything.
|
|
112
|
+
|
|
113
|
+
## Step 5 — Run it
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
bundle exec rspec spec/controllers/my_controller_spec.rb --example "MyComponent"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## When a rule here does not fit
|
|
120
|
+
|
|
121
|
+
The gem's README carries the reasoning behind these rules, plus troubleshooting
|
|
122
|
+
for generator errors. It ships inside the gem, so this reads the version the
|
|
123
|
+
app actually has:
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
cat "$(bundle show ts_schema_spec)/README.md"
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Sections: **Best practices**, **Gotchas**, **Troubleshooting**. If the command
|
|
130
|
+
fails, carry on — the rules above stand on their own.
|
metadata
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ts_schema_spec
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.5.4
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Vernon Coffey
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: json_schemer
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.0'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '3.0'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '2.0'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '3.0'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: nokogiri
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '1.10'
|
|
39
|
+
type: :runtime
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '1.10'
|
|
46
|
+
email:
|
|
47
|
+
- vccoffey@gmail.com
|
|
48
|
+
executables: []
|
|
49
|
+
extensions: []
|
|
50
|
+
extra_rdoc_files: []
|
|
51
|
+
files:
|
|
52
|
+
- LICENSE.txt
|
|
53
|
+
- README.md
|
|
54
|
+
- lib/ts_schema_spec.rb
|
|
55
|
+
- lib/ts_schema_spec/config.rb
|
|
56
|
+
- lib/ts_schema_spec/generator.rb
|
|
57
|
+
- lib/ts_schema_spec/railtie.rb
|
|
58
|
+
- lib/ts_schema_spec/react_component_props.rb
|
|
59
|
+
- lib/ts_schema_spec/rspec.rb
|
|
60
|
+
- lib/ts_schema_spec/skill.rb
|
|
61
|
+
- lib/ts_schema_spec/tasks/ts_schema_spec.rake
|
|
62
|
+
- lib/ts_schema_spec/version.rb
|
|
63
|
+
- skills/react-prop-type-spec/SKILL.md
|
|
64
|
+
homepage: https://github.com/nitidbit/ts_schema_spec
|
|
65
|
+
licenses:
|
|
66
|
+
- MIT
|
|
67
|
+
metadata:
|
|
68
|
+
homepage_uri: https://github.com/nitidbit/ts_schema_spec
|
|
69
|
+
source_code_uri: https://github.com/nitidbit/ts_schema_spec
|
|
70
|
+
rubygems_mfa_required: 'true'
|
|
71
|
+
rdoc_options: []
|
|
72
|
+
require_paths:
|
|
73
|
+
- lib
|
|
74
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
75
|
+
requirements:
|
|
76
|
+
- - ">="
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: '3.1'
|
|
79
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
80
|
+
requirements:
|
|
81
|
+
- - ">="
|
|
82
|
+
- !ruby/object:Gem::Version
|
|
83
|
+
version: '0'
|
|
84
|
+
requirements: []
|
|
85
|
+
rubygems_version: 4.0.20
|
|
86
|
+
specification_version: 4
|
|
87
|
+
summary: Assert that a Rails payload matches the TypeScript type the React side consumes.
|
|
88
|
+
test_files: []
|